@adecore/editor-react 0.22.1 → 0.23.0

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 (196) hide show
  1. package/dist/CallHierarchyPanel.d.ts +6 -0
  2. package/dist/CallHierarchyPanel.js +88 -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/PeekPanel.d.ts +3 -0
  15. package/dist/PeekPanel.js +2 -3
  16. package/dist/PickPopup.js +15 -2
  17. package/dist/RenameCard.js +10 -5
  18. package/dist/RenderedDocs.d.ts +9 -0
  19. package/dist/RenderedDocs.js +84 -0
  20. package/dist/SplitDiffView.d.ts +9 -0
  21. package/dist/SplitDiffView.js +276 -0
  22. package/dist/SymbolPicker.d.ts +3 -0
  23. package/dist/SymbolPicker.js +4 -3
  24. package/dist/call-hierarchy-model.d.ts +52 -0
  25. package/dist/call-hierarchy-model.js +99 -0
  26. package/dist/call-hierarchy.d.ts +42 -0
  27. package/dist/call-hierarchy.js +399 -0
  28. package/dist/code-actions-model.d.ts +2 -0
  29. package/dist/code-actions-model.js +12 -10
  30. package/dist/code-actions.js +3 -1
  31. package/dist/completion-model.d.ts +14 -4
  32. package/dist/completion-model.js +40 -33
  33. package/dist/completion.d.ts +4 -0
  34. package/dist/completion.js +257 -42
  35. package/dist/diagnostics.js +8 -6
  36. package/dist/diff-expansion.d.ts +9 -0
  37. package/dist/diff-expansion.js +41 -0
  38. package/dist/diff-overlays.d.ts +15 -0
  39. package/dist/diff-overlays.js +28 -0
  40. package/dist/diff-syntax.d.ts +16 -0
  41. package/dist/diff-syntax.js +49 -0
  42. package/dist/diff-wordmarks.d.ts +5 -0
  43. package/dist/diff-wordmarks.js +26 -0
  44. package/dist/doc-comment-model.d.ts +23 -0
  45. package/dist/doc-comment-model.js +291 -0
  46. package/dist/editor-language.d.ts +13 -1
  47. package/dist/editor-language.js +14 -4
  48. package/dist/editor-react.css +79 -0
  49. package/dist/format-on-type.d.ts +14 -0
  50. package/dist/format-on-type.js +96 -0
  51. package/dist/host-types.d.ts +17 -0
  52. package/dist/hover.d.ts +4 -0
  53. package/dist/hover.js +15 -2
  54. package/dist/index.d.ts +19 -5
  55. package/dist/index.js +9 -1
  56. package/dist/inlay-hints.d.ts +47 -2
  57. package/dist/inlay-hints.js +353 -16
  58. package/dist/language-ids.js +4 -2
  59. package/dist/locales/en.json +114 -3
  60. package/dist/locales/nl.json +114 -3
  61. package/dist/models.d.ts +6 -2
  62. package/dist/models.js +5 -1
  63. package/dist/patch-decorations.d.ts +27 -0
  64. package/dist/patch-decorations.js +182 -0
  65. package/dist/patch-layout.d.ts +40 -0
  66. package/dist/patch-layout.js +304 -0
  67. package/dist/peek.js +1 -0
  68. package/dist/pick.d.ts +1 -0
  69. package/dist/pick.js +11 -1
  70. package/dist/popups.d.ts +17 -0
  71. package/dist/popups.js +1 -0
  72. package/dist/project-language.d.ts +8 -0
  73. package/dist/project-language.js +34 -2
  74. package/dist/refresher.d.ts +1 -0
  75. package/dist/refresher.js +5 -0
  76. package/dist/rename-model.d.ts +1 -1
  77. package/dist/rename-model.js +3 -7
  78. package/dist/rename.js +5 -8
  79. package/dist/rendered-docs.d.ts +62 -0
  80. package/dist/rendered-docs.js +368 -0
  81. package/dist/row-host.d.ts +2 -0
  82. package/dist/row-host.js +11 -3
  83. package/dist/semantic-delta.d.ts +2 -0
  84. package/dist/semantic-delta.js +27 -0
  85. package/dist/semantic-model.js +16 -0
  86. package/dist/semantic-tokens.d.ts +26 -1
  87. package/dist/semantic-tokens.js +174 -14
  88. package/dist/shortcut-keys.d.ts +2 -0
  89. package/dist/shortcut-keys.js +6 -2
  90. package/dist/signature.js +1 -1
  91. package/dist/snippet-field-transforms.d.ts +30 -0
  92. package/dist/snippet-field-transforms.js +116 -0
  93. package/dist/snippet-indent.d.ts +2 -0
  94. package/dist/snippet-indent.js +49 -0
  95. package/dist/snippet-range-order.d.ts +3 -0
  96. package/dist/snippet-range-order.js +19 -0
  97. package/dist/snippet-session.d.ts +42 -6
  98. package/dist/snippet-session.js +650 -53
  99. package/dist/snippet-transform-client.d.ts +19 -0
  100. package/dist/snippet-transform-client.js +66 -0
  101. package/dist/snippet-transform-format.d.ts +15 -0
  102. package/dist/snippet-transform-format.js +302 -0
  103. package/dist/snippet-transform-protocol.d.ts +45 -0
  104. package/dist/snippet-transform-protocol.js +8 -0
  105. package/dist/snippet-transform-queue.d.ts +32 -0
  106. package/dist/snippet-transform-queue.js +173 -0
  107. package/dist/snippet-transform-worker.d.ts +10 -0
  108. package/dist/snippet-transform-worker.js +41 -0
  109. package/dist/snippet-variable-names.d.ts +1 -0
  110. package/dist/snippet-variable-names.js +36 -0
  111. package/dist/snippet-variables.d.ts +7 -0
  112. package/dist/snippet-variables.js +66 -0
  113. package/dist/snippet.d.ts +30 -3
  114. package/dist/snippet.js +402 -87
  115. package/dist/split-alignment.d.ts +6 -0
  116. package/dist/split-alignment.js +38 -0
  117. package/dist/split-scroll.d.ts +14 -0
  118. package/dist/split-scroll.js +45 -0
  119. package/dist/symbols.d.ts +2 -1
  120. package/dist/symbols.js +39 -7
  121. package/dist/testing.d.ts +5 -1
  122. package/dist/testing.js +12 -0
  123. package/dist/workspace-edit.d.ts +25 -3
  124. package/dist/workspace-edit.js +282 -143
  125. package/package.json +10 -5
  126. package/src/CallHierarchyPanel.tsx +238 -0
  127. package/src/CompletionPopup.tsx +12 -1
  128. package/src/DiffView.tsx +262 -0
  129. package/src/EditorContextMenu.tsx +1 -0
  130. package/src/EditorView.tsx +21 -2
  131. package/src/FindReplace.tsx +51 -13
  132. package/src/HoverCard.tsx +23 -21
  133. package/src/LanguagePopups.tsx +5 -0
  134. package/src/Markdown.tsx +10 -3
  135. package/src/PeekPanel.tsx +2 -3
  136. package/src/PickPopup.tsx +18 -2
  137. package/src/RenameCard.tsx +18 -0
  138. package/src/RenderedDocs.tsx +155 -0
  139. package/src/SplitDiffView.tsx +337 -0
  140. package/src/SymbolPicker.tsx +4 -3
  141. package/src/call-hierarchy-model.ts +158 -0
  142. package/src/call-hierarchy.ts +441 -0
  143. package/src/code-actions-model.ts +17 -11
  144. package/src/code-actions.ts +3 -1
  145. package/src/completion-model.ts +61 -35
  146. package/src/completion.ts +302 -42
  147. package/src/diagnostics.ts +8 -6
  148. package/src/diff-expansion.ts +49 -0
  149. package/src/diff-overlays.tsx +69 -0
  150. package/src/diff-syntax.ts +67 -0
  151. package/src/diff-wordmarks.ts +31 -0
  152. package/src/doc-comment-model.ts +336 -0
  153. package/src/editor-language.ts +21 -5
  154. package/src/editor-react.css +79 -0
  155. package/src/format-on-type.ts +111 -0
  156. package/src/host-types.ts +19 -0
  157. package/src/hover.ts +18 -3
  158. package/src/index.ts +27 -4
  159. package/src/inlay-hints.ts +394 -18
  160. package/src/language-ids.ts +4 -2
  161. package/src/locales/en.json +114 -3
  162. package/src/locales/nl.json +114 -3
  163. package/src/models.ts +40 -1
  164. package/src/patch-decorations.ts +224 -0
  165. package/src/patch-layout.ts +388 -0
  166. package/src/peek.ts +1 -0
  167. package/src/pick.ts +12 -1
  168. package/src/popups.ts +24 -0
  169. package/src/project-language.ts +43 -2
  170. package/src/refresher.ts +6 -0
  171. package/src/rename-model.ts +4 -8
  172. package/src/rename.ts +5 -8
  173. package/src/rendered-docs.ts +422 -0
  174. package/src/row-host.ts +12 -3
  175. package/src/semantic-delta.ts +29 -0
  176. package/src/semantic-model.ts +17 -0
  177. package/src/semantic-tokens.ts +202 -19
  178. package/src/shortcut-keys.ts +6 -2
  179. package/src/signature.ts +1 -1
  180. package/src/snippet-field-transforms.ts +144 -0
  181. package/src/snippet-indent.ts +53 -0
  182. package/src/snippet-range-order.ts +19 -0
  183. package/src/snippet-session.ts +797 -56
  184. package/src/snippet-transform-client.ts +84 -0
  185. package/src/snippet-transform-format.ts +325 -0
  186. package/src/snippet-transform-protocol.ts +24 -0
  187. package/src/snippet-transform-queue.ts +199 -0
  188. package/src/snippet-transform-worker.ts +58 -0
  189. package/src/snippet-variable-names.ts +36 -0
  190. package/src/snippet-variables.ts +74 -0
  191. package/src/snippet.ts +452 -89
  192. package/src/split-alignment.ts +45 -0
  193. package/src/split-scroll.ts +54 -0
  194. package/src/symbols.ts +43 -7
  195. package/src/testing.ts +25 -0
  196. package/src/workspace-edit.ts +347 -169
@@ -0,0 +1,441 @@
1
+ import { StaleResultError, type CallHierarchyItem } from '@adecore/lsp';
2
+ import type { EditorPosition } from '@adecore/editor';
3
+ import type { EditorLanguage } from './editor-language.ts';
4
+ import {
5
+ CALL_LEVEL_LIMIT,
6
+ childrenOf,
7
+ moreIdOf,
8
+ rootsOf,
9
+ rowsOf,
10
+ statusIdOf,
11
+ stepsOf,
12
+ targetOf,
13
+ type CallDirection,
14
+ type CallNode,
15
+ type CallRow
16
+ } from './call-hierarchy-model.ts';
17
+ import { PEEK_HEIGHT } from './peek.ts';
18
+ import { snippetOf } from './peek-model.ts';
19
+ import { isShortcut } from './shortcut-keys.ts';
20
+ import { messageOf } from './error-message.ts';
21
+ import { realTimers, type Timers } from './timers.ts';
22
+
23
+ const TOAST_ID = 'language-call-hierarchy';
24
+ const PREPARE = 'textDocument/prepareCallHierarchy';
25
+
26
+ /* The row the panel stands in is as tall as a peek's, plus the segmented control that is a pixel taller than the peek's title bar. */
27
+ export const CALL_HIERARCHY_HEIGHT = PEEK_HEIGHT + 2;
28
+
29
+ const OWNER = 'call-hierarchy';
30
+
31
+ /*
32
+ * The callers of the symbol at the caret, or the symbols it calls, as a tree between the lines of the file
33
+ * under the line the caret is on, with the code around the call that is active. A node asks the server for its
34
+ * own calls when it is opened, and a level shows its first calls with a row that brings the others.
35
+ * The editor keeps the keyboard: arrows move and open, Enter goes to the call, Escape closes, and the
36
+ * shortcut that opened the tree switches between callers and callees. Typing closes it.
37
+ */
38
+ export class CallHierarchyFeature {
39
+ private readonly language: EditorLanguage;
40
+ private readonly timers: Timers;
41
+ private nodes = new Map<string, CallNode>();
42
+ private roots: string[] = [];
43
+ private prepared: CallHierarchyItem[] = [];
44
+ private direction: CallDirection = 'incoming';
45
+ private controller = new AbortController();
46
+ /* Raised whenever the tree starts over or closes, so an answer for the tree that was never lands in the one that is. */
47
+ private epoch = 0;
48
+ private previewEpoch = 0;
49
+ private texts = new Map<string, string>();
50
+
51
+ constructor(language: EditorLanguage, timers: Timers = realTimers) {
52
+ this.language = language;
53
+ this.timers = timers;
54
+ const { editor } = language;
55
+ const offs = [
56
+ editor.onKeyDown((event) => {
57
+ if (isShortcut(this.language.shortcuts.callHierarchy, event)) {
58
+ if (this.isOpen) {
59
+ this.setDirection(this.direction === 'incoming' ? 'outgoing' : 'incoming');
60
+ } else {
61
+ void this.open();
62
+ }
63
+ return true;
64
+ }
65
+ return this.key(event);
66
+ }),
67
+ editor.onTextChange(() => this.close())
68
+ ];
69
+ language.onDispose(() => {
70
+ for (const off of offs) {
71
+ off();
72
+ }
73
+ this.close();
74
+ });
75
+ }
76
+
77
+ get isOpen(): boolean {
78
+ return (this.language.popups.getState().callHierarchy ?? null) !== null;
79
+ }
80
+
81
+ /* Whether a server of this document answers the three requests the tree is made of. */
82
+ get supported(): boolean {
83
+ const { project, uri } = this.language;
84
+ const { service } = project;
85
+ return (
86
+ service.prepareCallHierarchy !== undefined &&
87
+ service.incomingCalls !== undefined &&
88
+ service.outgoingCalls !== undefined &&
89
+ service.supports(PREPARE, uri)
90
+ );
91
+ }
92
+
93
+ /* Opens the tree for the symbol at the position, on its callers unless told otherwise. */
94
+ async open(position: EditorPosition = this.language.editor.getCaret(), direction: CallDirection = 'incoming'): Promise<void> {
95
+ const { editor, project, uri } = this.language;
96
+ if (!this.supported) {
97
+ this.tell(this.say('unavailable'));
98
+ return;
99
+ }
100
+ this.close();
101
+ const epoch = this.epoch;
102
+ let items: CallHierarchyItem[] | null;
103
+ try {
104
+ items = await project.service.prepareCallHierarchy!(uri, position, { signal: this.controller.signal });
105
+ } catch (error) {
106
+ if (epoch === this.epoch && !(error instanceof StaleResultError)) {
107
+ this.tell(this.say('failed', { message: messageOf(error) }));
108
+ }
109
+ return;
110
+ }
111
+ if (epoch !== this.epoch) {
112
+ return;
113
+ }
114
+ if (items === null || items.length === 0) {
115
+ this.tell(this.say('none'));
116
+ return;
117
+ }
118
+ this.language.peek.close();
119
+ this.prepared = items;
120
+ this.direction = direction;
121
+ this.seed();
122
+ const active = this.roots[0]!;
123
+ this.language.popups.setState({
124
+ callHierarchy: { direction, container: null, name: items[0]!.name, rows: rowsOf(this.nodes, this.roots), active, site: 0, sites: 0, preview: null }
125
+ });
126
+ this.select(active);
127
+ // The editor draws a row once per id, so a tree opened over another needs an id of its own to get a container.
128
+ editor.setWidgets(
129
+ [{ id: `call-hierarchy-${epoch}`, line: position.line, height: CALL_HIERARCHY_HEIGHT, render: (container) => this.mounted(container) }],
130
+ OWNER
131
+ );
132
+ editor.focus();
133
+ for (const root of this.roots) {
134
+ void this.load(root);
135
+ }
136
+ }
137
+
138
+ close(): void {
139
+ this.reset();
140
+ if (this.language.popups.getState().callHierarchy == null) {
141
+ return;
142
+ }
143
+ this.language.popups.setState({ callHierarchy: null });
144
+ this.language.editor.setWidgets([], OWNER);
145
+ this.language.editor.setInputPopup(null);
146
+ this.language.editor.focus();
147
+ }
148
+
149
+ /* Starts the tree over from the same symbols for the other direction. */
150
+ setDirection(direction: CallDirection): void {
151
+ const view = this.language.popups.getState().callHierarchy ?? null;
152
+ if (view === null || view.direction === direction) {
153
+ return;
154
+ }
155
+ this.reset();
156
+ this.direction = direction;
157
+ this.seed();
158
+ const active = this.roots[0]!;
159
+ this.language.popups.setState({
160
+ callHierarchy: { ...view, direction, rows: rowsOf(this.nodes, this.roots), active, site: 0, sites: 0, preview: null }
161
+ });
162
+ this.select(active);
163
+ for (const root of this.roots) {
164
+ void this.load(root);
165
+ }
166
+ }
167
+
168
+ /* Opens a node, asking the server for its calls the first time, or folds it away. */
169
+ toggle(id: string): void {
170
+ const node = this.nodes.get(id);
171
+ if (node === undefined) {
172
+ return;
173
+ }
174
+ if (node.expanded) {
175
+ this.patch(id, { expanded: false });
176
+ const view = this.language.popups.getState().callHierarchy ?? null;
177
+ if (view !== null && this.hasAncestor(view.active, id)) {
178
+ this.select(id);
179
+ }
180
+ return;
181
+ }
182
+ this.patch(id, { expanded: true });
183
+ if (node.load === 'idle' || node.load === 'failed') {
184
+ void this.load(id);
185
+ }
186
+ }
187
+
188
+ /* Asks again for the calls of a node whose last try failed. */
189
+ retry(id: string): void {
190
+ if (this.nodes.get(id)?.load === 'failed') {
191
+ void this.load(id);
192
+ }
193
+ }
194
+
195
+ /* Brings the next stretch of calls of a level. */
196
+ showMore(parent: string): void {
197
+ const node = this.nodes.get(parent);
198
+ if (node === undefined) {
199
+ return;
200
+ }
201
+ this.patch(parent, { shown: Math.min(node.children.length, node.shown + CALL_LEVEL_LIMIT) });
202
+ const first = node.children[node.shown];
203
+ const view = this.language.popups.getState().callHierarchy ?? null;
204
+ // The row that offered more is gone once everything shows, so the selection moves on to what it brought.
205
+ if (first !== undefined && view?.active === moreIdOf(parent)) {
206
+ this.select(first);
207
+ }
208
+ }
209
+
210
+ /* Makes a row the active one and shows the code of its call. */
211
+ select(id: string, site = 0): void {
212
+ const view = this.language.popups.getState().callHierarchy ?? null;
213
+ const node = this.nodes.get(id);
214
+ if (view === null || !view.rows.some((row) => row.id === id)) {
215
+ return;
216
+ }
217
+ const sites = node?.sites.length ?? view.sites;
218
+ const next = node === undefined ? view.site : Math.max(0, Math.min(Math.max(0, sites - 1), site));
219
+ this.language.popups.setState({ callHierarchy: { ...view, active: id, site: next, sites, preview: node === undefined ? view.preview : null } });
220
+ if (node !== undefined) {
221
+ void this.preview(node, next);
222
+ }
223
+ }
224
+
225
+ /* Steps through the places a call is made from when the active one has more than one. */
226
+ stepSite(direction: 1 | -1): void {
227
+ const view = this.language.popups.getState().callHierarchy ?? null;
228
+ if (view !== null && view.sites > 1) {
229
+ this.select(view.active, (view.site + direction + view.sites) % view.sites);
230
+ }
231
+ }
232
+
233
+ /* Goes to the call of a row, or to the symbol of a root, and closes the tree. */
234
+ go(id: string): void {
235
+ const view = this.language.popups.getState().callHierarchy ?? null;
236
+ const node = this.nodes.get(id);
237
+ if (view === null || node === undefined) {
238
+ return;
239
+ }
240
+ const { uri, range } = targetOf(node, id === view.active ? view.site : 0);
241
+ this.close();
242
+ this.language.goTo({ uri, range });
243
+ }
244
+
245
+ private reset(): void {
246
+ this.epoch++;
247
+ this.previewEpoch++;
248
+ this.controller.abort();
249
+ this.controller = new AbortController();
250
+ this.nodes = new Map();
251
+ this.roots = [];
252
+ this.texts = new Map();
253
+ }
254
+
255
+ private seed(): void {
256
+ const roots = rootsOf(this.prepared);
257
+ this.roots = roots.map((root) => root.id);
258
+ this.nodes = new Map(roots.map((root) => [root.id, root]));
259
+ }
260
+
261
+ private mounted(container: HTMLElement): void {
262
+ const view = this.language.popups.getState().callHierarchy ?? null;
263
+ if (view !== null && view.container !== container) {
264
+ this.language.popups.setState({ callHierarchy: { ...view, container } });
265
+ // The row opens under the caret line, which may sit at the bottom of the view with the tree below the fold. The element is in the page a turn later.
266
+ this.timers.set(() => container.scrollIntoView?.({ block: 'nearest' }), 0);
267
+ }
268
+ }
269
+
270
+ private patch(id: string, change: Partial<CallNode>): void {
271
+ const node = this.nodes.get(id);
272
+ const view = this.language.popups.getState().callHierarchy ?? null;
273
+ if (node === undefined || view === null) {
274
+ return;
275
+ }
276
+ this.nodes.set(id, { ...node, ...change });
277
+ this.language.popups.setState({ callHierarchy: { ...view, rows: rowsOf(this.nodes, this.roots) } });
278
+ }
279
+
280
+ private async load(id: string): Promise<void> {
281
+ const node = this.nodes.get(id);
282
+ if (node === undefined || node.load === 'loading') {
283
+ return;
284
+ }
285
+ const { service, i18n } = this.language.project;
286
+ const { uri, editor } = this.language;
287
+ const { direction, epoch } = this;
288
+ const options = { signal: this.controller.signal, parallel: true };
289
+ this.patch(id, { load: 'loading', error: null });
290
+ try {
291
+ const calls =
292
+ direction === 'incoming' ? await service.incomingCalls!(uri, node.item, options) : await service.outgoingCalls!(uri, node.item, options);
293
+ if (epoch !== this.epoch) {
294
+ return;
295
+ }
296
+ const children = childrenOf(direction, node, calls ?? []);
297
+ for (const child of children) {
298
+ this.nodes.set(child.id, child);
299
+ }
300
+ this.patch(id, { load: 'loaded', children: children.map((child) => child.id), shown: Math.min(children.length, CALL_LEVEL_LIMIT) });
301
+ editor.announce(i18n.t(`editor:language.callHierarchy.announce.${direction}`, { count: children.length, name: node.item.name }));
302
+ } catch (error) {
303
+ if (epoch !== this.epoch) {
304
+ return;
305
+ }
306
+ if (error instanceof StaleResultError) {
307
+ this.close();
308
+ return;
309
+ }
310
+ this.patch(id, { load: 'failed', error: messageOf(error) });
311
+ }
312
+ }
313
+
314
+ private async preview(node: CallNode, site: number): Promise<void> {
315
+ const { project } = this.language;
316
+ const epoch = ++this.previewEpoch;
317
+ const { uri, range } = targetOf(node, site);
318
+ let text = this.texts.get(uri);
319
+ if (text === undefined) {
320
+ try {
321
+ text = (await project.readText(uri)) ?? undefined;
322
+ } catch {
323
+ // A file that cannot be read shows no code, as one that is no text file does.
324
+ text = undefined;
325
+ }
326
+ if (epoch !== this.previewEpoch) {
327
+ return;
328
+ }
329
+ if (text !== undefined) {
330
+ this.texts.set(uri, text);
331
+ }
332
+ }
333
+ const view = this.language.popups.getState().callHierarchy ?? null;
334
+ if (view === null || view.active !== node.id) {
335
+ return;
336
+ }
337
+ const snippet = text === undefined ? null : snippetOf(text, range.start.line, range);
338
+ this.language.popups.setState({ callHierarchy: { ...view, preview: snippet === null ? null : { ...snippet, uri } } });
339
+ }
340
+
341
+ /* Whether a row stands under a node; the `more` and `status` rows belong to the node they are about. */
342
+ private hasAncestor(id: string, ancestor: string): boolean {
343
+ const owner = [moreIdOf(''), statusIdOf('')]
344
+ .map((suffix) => (id.endsWith(suffix) ? id.slice(0, -suffix.length) : null))
345
+ .find((found) => found !== null);
346
+ if (owner !== undefined && owner !== null) {
347
+ return owner === ancestor || this.hasAncestor(owner, ancestor);
348
+ }
349
+ for (let node = this.nodes.get(id); node !== undefined && node.parent !== null; node = this.nodes.get(node.parent)) {
350
+ if (node.parent === ancestor) {
351
+ return true;
352
+ }
353
+ }
354
+ return false;
355
+ }
356
+
357
+ private key(event: KeyboardEvent): boolean {
358
+ const view = this.language.popups.getState().callHierarchy ?? null;
359
+ if (view === null || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.isComposing) {
360
+ return false;
361
+ }
362
+ const steps = stepsOf(view.rows);
363
+ const index = steps.findIndex((row) => row.id === view.active);
364
+ const row: CallRow | undefined = steps[index];
365
+ switch (event.key) {
366
+ case 'ArrowDown':
367
+ this.land(steps[Math.min(steps.length - 1, index + 1)]);
368
+ return true;
369
+ case 'ArrowUp':
370
+ this.land(steps[Math.max(0, index - 1)]);
371
+ return true;
372
+ case 'Home':
373
+ this.land(steps[0]);
374
+ return true;
375
+ case 'End':
376
+ this.land(steps.at(-1));
377
+ return true;
378
+ case 'ArrowRight':
379
+ this.expandOrEnter(row, steps[index + 1]);
380
+ return true;
381
+ case 'ArrowLeft':
382
+ this.collapseOrLeave(row);
383
+ return true;
384
+ case 'Enter':
385
+ if (row?.kind === 'more') {
386
+ this.showMore(row.parent);
387
+ } else if (row !== undefined) {
388
+ this.go(row.id);
389
+ }
390
+ return true;
391
+ case 'Escape':
392
+ this.close();
393
+ return true;
394
+ default:
395
+ return false;
396
+ }
397
+ }
398
+
399
+ private land(row: CallRow | undefined): void {
400
+ if (row !== undefined) {
401
+ this.select(row.id);
402
+ }
403
+ }
404
+
405
+ /* Right opens a closed node, tries a failed one again, and steps into an open one. */
406
+ private expandOrEnter(row: CallRow | undefined, next: CallRow | undefined): void {
407
+ if (row?.kind !== 'node') {
408
+ return;
409
+ }
410
+ if (!row.expanded) {
411
+ this.toggle(row.id);
412
+ } else if (row.load === 'failed') {
413
+ this.retry(row.id);
414
+ } else if (next !== undefined && next.level > row.level) {
415
+ this.land(next);
416
+ }
417
+ }
418
+
419
+ /* Left closes an open node and steps out of a closed one. */
420
+ private collapseOrLeave(row: CallRow | undefined): void {
421
+ if (row === undefined) {
422
+ return;
423
+ }
424
+ if (row.kind === 'node' && row.expanded && !row.leaf) {
425
+ this.toggle(row.id);
426
+ return;
427
+ }
428
+ const parent = row.kind === 'node' ? (this.nodes.get(row.id)?.parent ?? null) : row.parent;
429
+ if (parent !== null) {
430
+ this.select(parent);
431
+ }
432
+ }
433
+
434
+ private tell(title: string): void {
435
+ this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title });
436
+ }
437
+
438
+ private say(key: string, options?: Record<string, unknown>): string {
439
+ return this.language.project.i18n.t(`editor:language.callHierarchy.${key}`, options);
440
+ }
441
+ }
@@ -1,6 +1,6 @@
1
1
  import { applyTextEdits, type CodeAction, type Command, type Diagnostic, type Range, type WorkspaceEdit } from '@adecore/lsp';
2
2
  import { comparePositions } from './diagnostics-model.ts';
3
- import { entriesOf, renamesOf } from './workspace-edit.ts';
3
+ import { resourceOperationsOf, textEntriesOf } from './workspace-edit.ts';
4
4
 
5
5
  export type ActionGroup = 'quickfix' | 'rewrite' | 'extract' | 'inline' | 'move' | 'refactor' | 'source' | 'other';
6
6
 
@@ -97,10 +97,14 @@ export interface EditPreview {
97
97
  readonly added: readonly string[];
98
98
  /* Lines left out of `removed` and `added` to keep the preview short. */
99
99
  readonly hiddenLines: number;
100
- /* The other files the edit reaches. */
100
+ /* The other files the edit edits, apart from the ones it makes. */
101
101
  readonly otherFiles: number;
102
102
  /* The files it moves. */
103
103
  readonly moves: number;
104
+ /* The files it makes. */
105
+ readonly creates?: number;
106
+ /* The files it deletes. */
107
+ readonly deletes?: number;
104
108
  }
105
109
 
106
110
  const PREVIEW_LINES = 3;
@@ -109,15 +113,17 @@ function linesOf(text: string): string[] {
109
113
  return text.split(/\r\n|\r|\n/);
110
114
  }
111
115
 
112
- /* Null when the edit changes nothing, or deletes a file, which a preview of lines cannot show. A file it creates counts as another file. */
116
+ /* Null when the edit changes nothing. A file it creates is counted under `creates` and not as another file; moves and deletes are counted apart. */
113
117
  export function previewOf(text: string, edit: WorkspaceEdit, uri: string): EditPreview | null {
114
- const entries = entriesOf(edit);
115
- if (entries === null) {
116
- return null;
117
- }
118
+ const entries = textEntriesOf(edit);
119
+ const operations = resourceOperationsOf(edit);
120
+ const count = (kind: string): number => operations.filter((operation) => operation.kind === kind).length;
121
+ const moves = count('rename');
122
+ const creates = count('create');
123
+ const deletes = count('delete');
118
124
  const own = entries.get(uri);
119
- const moves = renamesOf(edit).length;
120
- const otherFiles = [...entries.keys()].filter((key) => key !== uri).length;
125
+ const made = new Set(operations.filter((operation) => operation.kind === 'create').map((operation) => operation.uri));
126
+ const otherFiles = [...entries.keys()].filter((key) => key !== uri && !made.has(key)).length;
121
127
  let removed: string[] = [];
122
128
  let added: string[] = [];
123
129
  if (own !== undefined) {
@@ -140,9 +146,9 @@ export function previewOf(text: string, edit: WorkspaceEdit, uri: string): EditP
140
146
  removed = before.slice(head, before.length - tail);
141
147
  added = next.slice(head, next.length - tail);
142
148
  }
143
- if (removed.length === 0 && added.length === 0 && otherFiles === 0 && moves === 0) {
149
+ if (removed.length === 0 && added.length === 0 && otherFiles === 0 && moves === 0 && creates === 0 && deletes === 0) {
144
150
  return null;
145
151
  }
146
152
  const hiddenLines = Math.max(0, removed.length - PREVIEW_LINES) + Math.max(0, added.length - PREVIEW_LINES);
147
- return { removed: removed.slice(0, PREVIEW_LINES), added: added.slice(0, PREVIEW_LINES), hiddenLines, otherFiles, moves };
153
+ return { removed: removed.slice(0, PREVIEW_LINES), added: added.slice(0, PREVIEW_LINES), hiddenLines, otherFiles, moves, creates, deletes };
148
154
  }
@@ -314,7 +314,9 @@ export class CodeActionsFeature {
314
314
  const notes = [
315
315
  ...(found.hiddenLines > 0 ? [this.say('moreLines', { count: found.hiddenLines })] : []),
316
316
  ...(found.otherFiles > 0 ? [this.say('otherFiles', { count: found.otherFiles })] : []),
317
- ...(found.moves > 0 ? [this.say('moves', { count: found.moves })] : [])
317
+ ...(found.moves > 0 ? [this.say('moves', { count: found.moves })] : []),
318
+ ...((found.creates ?? 0) > 0 ? [this.say('creates', { count: found.creates })] : []),
319
+ ...((found.deletes ?? 0) > 0 ? [this.say('deletes', { count: found.deletes })] : [])
318
320
  ];
319
321
  return { removed: found.removed, added: found.added, note: notes.length === 0 ? null : notes.join(' · ') };
320
322
  }
@@ -1,9 +1,11 @@
1
+ import type { SnippetTransformJob } from './snippet-transform-protocol.ts';
1
2
  import type { EditorContentChange, EditorPosition, EditorRange } from '@adecore/editor';
2
- import type { CompletionItem, CompletionList, CompletionResult, MarkupContent } from '@adecore/lsp';
3
+ import { applyCompletionDefaults, type CompletionItem, type CompletionResult, type MarkupContent } from '@adecore/lsp';
3
4
  import { comparePositions } from './diagnostics-model.ts';
4
5
  import { escapeMarkdown, isEmptyHover, splitSignatures, type HoverText } from './hover-content.ts';
5
6
  import type { CompletionDocs } from './popups.ts';
6
- import { parseSnippet, tabOrder, type SnippetStop } from './snippet.ts';
7
+ import { compileSnippet, parseSnippet, tabOrder, type SnippetField, type SnippetStop, type SnippetTemplate, type SnippetVariableResolver } from './snippet.ts';
8
+ import { indentSnippet } from './snippet-indent.ts';
7
9
 
8
10
  const IDENTIFIER_CHARACTER = /[\p{L}\p{N}\p{M}_$]/u;
9
11
 
@@ -149,35 +151,22 @@ export function editRangeOf(item: CompletionItem, replace: boolean): EditorRange
149
151
 
150
152
  /* Items from a result, with the list's defaults filled in so each carries its own edit range and format. */
151
153
  export function itemsOf(result: CompletionResult): { items: CompletionItem[]; incomplete: boolean } {
152
- if (result === null) {
153
- return { items: [], incomplete: false };
154
- }
155
- if (Array.isArray(result)) {
156
- return { items: result, incomplete: false };
157
- }
158
- const list: CompletionList = result;
159
- const defaults = list.itemDefaults;
160
- if (defaults === undefined) {
161
- return { items: list.items, incomplete: list.isIncomplete };
162
- }
154
+ const list = result === null || Array.isArray(result) ? undefined : result;
155
+ const items = result === null ? [] : Array.isArray(result) ? result : result.items;
163
156
  return {
164
- incomplete: list.isIncomplete,
165
- items: list.items.map((item) => {
166
- const next: CompletionItem = { ...item };
167
- if (defaults.insertTextFormat !== undefined && next.insertTextFormat === undefined) {
168
- next.insertTextFormat = defaults.insertTextFormat;
157
+ incomplete: list?.isIncomplete ?? false,
158
+ items: items.flatMap((item) => {
159
+ if (typeof item?.label !== 'string') {
160
+ return [];
169
161
  }
170
- if (defaults.commitCharacters !== undefined && next.commitCharacters === undefined) {
171
- next.commitCharacters = defaults.commitCharacters;
162
+ try {
163
+ return [applyCompletionDefaults(item, list?.itemDefaults, { onInvalidRange: () => undefined })];
164
+ } catch (error) {
165
+ if (error instanceof TypeError || error instanceof RangeError) {
166
+ return [];
167
+ }
168
+ throw error;
172
169
  }
173
- if (next.textEdit === undefined && defaults.editRange !== undefined) {
174
- const text = item.textEditText ?? item.insertText ?? item.label;
175
- next.textEdit =
176
- 'start' in defaults.editRange
177
- ? { range: defaults.editRange, newText: text }
178
- : { insert: defaults.editRange.insert, replace: defaults.editRange.replace, newText: text };
179
- }
180
- return next;
181
170
  })
182
171
  };
183
172
  }
@@ -291,17 +280,35 @@ function withoutCall(snippet: string): string {
291
280
  }
292
281
 
293
282
  export interface Insertion {
283
+ readonly limited?: true;
294
284
  readonly range: EditorRange;
295
285
  readonly text: string;
296
286
  /* Where Tab goes in the inserted text, in order; empty for plain text and for a snippet without stops. */
297
287
  readonly stops: readonly SnippetStop[];
288
+ readonly fields?: readonly SnippetField[];
289
+ readonly variableTransforms?: readonly SnippetTransformJob[];
290
+ }
291
+
292
+ export interface SnippetInsertionOptions {
293
+ readonly resolve?: SnippetVariableResolver;
294
+ readonly variableTransforms?: readonly string[];
295
+ readonly template?: SnippetTemplate;
296
+ /* The line before the edit start when the edit starts on another line than the caret. */
297
+ readonly indentationBefore?: string;
298
298
  }
299
299
 
300
300
  /*
301
301
  * What goes into the text for an item and the range it takes the place of, given the caret. `lineAfter` is
302
302
  * the rest of the line after the caret: an item that would add a call does not when a parenthesis follows.
303
303
  */
304
- export function insertionOf(item: CompletionItem, caret: EditorPosition, lineBefore: string, replace: boolean, lineAfter = ''): Insertion {
304
+ export function insertionOf(
305
+ item: CompletionItem,
306
+ caret: EditorPosition,
307
+ lineBefore: string,
308
+ replace: boolean,
309
+ lineAfter = '',
310
+ options: SnippetInsertionOptions = {}
311
+ ): Insertion {
305
312
  const raw = item.textEdit?.newText ?? item.insertText ?? item.label;
306
313
  const edit = editRangeOf(item, replace);
307
314
  const range =
@@ -310,11 +317,29 @@ export function insertionOf(item: CompletionItem, caret: EditorPosition, lineBef
310
317
  : // The word grew since the answer came, so what was typed since belongs to what is replaced.
311
318
  { start: edit.start, end: comparePositions(edit.end, caret) < 0 && edit.end.line === caret.line ? caret : edit.end };
312
319
  if (item.insertTextFormat !== SNIPPET_FORMAT) {
313
- return { range, text: raw, stops: [] };
320
+ const adjusted =
321
+ item.insertTextMode === 2
322
+ ? indentSnippet({ text: raw, stops: [] }, options.indentationBefore ?? lineBefore.slice(0, range.start.character))
323
+ : { text: raw, stops: [] };
324
+ return { range, text: adjusted.text, stops: [], ...(adjusted.limited ? { limited: true } : {}) };
314
325
  }
315
326
  const followed = range.end.line === caret.line && lineAfter.slice(Math.max(0, range.end.character - caret.character)).startsWith('(');
316
- const parsed = parseSnippet(followed && isCallableKind(item.kind) ? withoutCall(raw) : raw);
317
- return { range, text: parsed.text, stops: tabOrder(parsed) };
327
+ const source = followed && isCallableKind(item.kind) ? withoutCall(raw) : raw;
328
+ const template = options.template?.source === source ? options.template : compileSnippet(source);
329
+ const rendered = template.render(options.resolve, options.variableTransforms);
330
+ const parsed = item.insertTextMode === 2 ? indentSnippet(rendered, options.indentationBefore ?? lineBefore.slice(0, range.start.character)) : rendered;
331
+ return {
332
+ range,
333
+ text: parsed.text,
334
+ stops: tabOrder(parsed),
335
+ fields: parsed.fields,
336
+ ...(options.variableTransforms !== undefined || rendered.transforms === undefined
337
+ ? {}
338
+ : {
339
+ variableTransforms: rendered.transforms.map((range) => ({ ...range.transform, value: rendered.text.slice(range.start, range.end) }))
340
+ }),
341
+ ...(parsed.limited ? { limited: true } : {})
342
+ };
318
343
  }
319
344
 
320
345
  /*
@@ -326,7 +351,8 @@ export function mirroredInsertions(
326
351
  primary: EditorRange,
327
352
  caret: EditorPosition,
328
353
  text: (range: EditorRange) => string,
329
- insertion: Insertion
354
+ insertion: Insertion,
355
+ includeSelections = false
330
356
  ): EditorContentChange[] {
331
357
  const before = insertion.range.start.line === caret.line ? caret.character - insertion.range.start.character : -1;
332
358
  const after = insertion.range.end.line === caret.line ? insertion.range.end.character - caret.character : -1;
@@ -336,11 +362,11 @@ export function mirroredInsertions(
336
362
  const typed = text({ start: insertion.range.start, end: caret });
337
363
  const replaced = text({ start: caret, end: insertion.range.end });
338
364
  return carets.flatMap((other): EditorContentChange[] => {
339
- const at = other.start;
365
+ const at = includeSelections ? other.end : other.start;
340
366
  if (comparePositions(other.start, primary.start) === 0 && comparePositions(other.end, primary.end) === 0) {
341
367
  return [];
342
368
  }
343
- if (comparePositions(other.start, other.end) !== 0 || at.character < before) {
369
+ if ((!includeSelections && comparePositions(other.start, other.end) !== 0) || at.character < before) {
344
370
  return [];
345
371
  }
346
372
  const range = { start: { line: at.line, character: at.character - before }, end: { line: at.line, character: at.character + after } };