@adecore/editor-react 0.0.1 → 0.17.0-beta.2

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 (264) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +60 -0
  3. package/dist/AnchoredPopup.d.ts +12 -0
  4. package/dist/AnchoredPopup.js +27 -0
  5. package/dist/AttributionCard.d.ts +14 -0
  6. package/dist/AttributionCard.js +5 -0
  7. package/dist/BaselineStatus.d.ts +4 -0
  8. package/dist/BaselineStatus.js +13 -0
  9. package/dist/ChangeReview.d.ts +12 -0
  10. package/dist/ChangeReview.js +27 -0
  11. package/dist/CodeAuthorsCard.d.ts +6 -0
  12. package/dist/CodeAuthorsCard.js +22 -0
  13. package/dist/CompletionPopup.d.ts +8 -0
  14. package/dist/CompletionPopup.js +103 -0
  15. package/dist/EditorContextMenu.d.ts +8 -0
  16. package/dist/EditorContextMenu.js +23 -0
  17. package/dist/EditorView.d.ts +16 -0
  18. package/dist/EditorView.js +27 -0
  19. package/dist/FindReplace.d.ts +6 -0
  20. package/dist/FindReplace.js +44 -0
  21. package/dist/HoverCard.d.ts +11 -0
  22. package/dist/HoverCard.js +38 -0
  23. package/dist/HoverSections.d.ts +21 -0
  24. package/dist/HoverSections.js +82 -0
  25. package/dist/LanguagePopups.d.ts +6 -0
  26. package/dist/LanguagePopups.js +38 -0
  27. package/dist/Markdown.d.ts +3 -0
  28. package/dist/Markdown.js +7 -0
  29. package/dist/PathText.d.ts +4 -0
  30. package/dist/PathText.js +12 -0
  31. package/dist/PeekPanel.d.ts +6 -0
  32. package/dist/PeekPanel.js +111 -0
  33. package/dist/PickPopup.d.ts +8 -0
  34. package/dist/PickPopup.js +33 -0
  35. package/dist/RenameCard.d.ts +9 -0
  36. package/dist/RenameCard.js +108 -0
  37. package/dist/SignatureCard.d.ts +4 -0
  38. package/dist/SignatureCard.js +8 -0
  39. package/dist/SymbolPicker.d.ts +6 -0
  40. package/dist/SymbolPicker.js +108 -0
  41. package/dist/code-actions-model.d.ts +23 -0
  42. package/dist/code-actions-model.js +114 -0
  43. package/dist/code-actions.d.ts +30 -0
  44. package/dist/code-actions.js +326 -0
  45. package/dist/code-authors.d.ts +18 -0
  46. package/dist/code-authors.js +79 -0
  47. package/dist/code-vision-model.d.ts +21 -0
  48. package/dist/code-vision-model.js +183 -0
  49. package/dist/code-vision.d.ts +48 -0
  50. package/dist/code-vision.js +236 -0
  51. package/dist/completion-call.d.ts +24 -0
  52. package/dist/completion-call.js +110 -0
  53. package/dist/completion-model.d.ts +51 -0
  54. package/dist/completion-model.js +353 -0
  55. package/dist/completion.d.ts +43 -0
  56. package/dist/completion.js +463 -0
  57. package/dist/conflict-model.d.ts +15 -0
  58. package/dist/conflict-model.js +36 -0
  59. package/dist/context-menu.d.ts +10 -0
  60. package/dist/context-menu.js +55 -0
  61. package/dist/definition-link.d.ts +14 -0
  62. package/dist/definition-link.js +99 -0
  63. package/dist/diagnostics-model.d.ts +15 -0
  64. package/dist/diagnostics-model.js +83 -0
  65. package/dist/diagnostics.d.ts +23 -0
  66. package/dist/diagnostics.js +95 -0
  67. package/dist/editor-language.d.ts +65 -0
  68. package/dist/editor-language.js +170 -0
  69. package/dist/editor-react.css +43 -0
  70. package/dist/find-query.d.ts +26 -0
  71. package/dist/find-query.js +51 -0
  72. package/dist/folding.d.ts +16 -0
  73. package/dist/folding.js +95 -0
  74. package/dist/highlight-layers.d.ts +10 -0
  75. package/dist/highlight-layers.js +30 -0
  76. package/dist/highlights.d.ts +16 -0
  77. package/dist/highlights.js +102 -0
  78. package/dist/history.d.ts +8 -0
  79. package/dist/history.js +46 -0
  80. package/dist/holder.d.ts +5 -0
  81. package/dist/holder.js +19 -0
  82. package/dist/host-types.d.ts +45 -0
  83. package/dist/host-types.js +1 -0
  84. package/dist/hover-content.d.ts +45 -0
  85. package/dist/hover-content.js +198 -0
  86. package/dist/hover.d.ts +32 -0
  87. package/dist/hover.js +260 -0
  88. package/dist/index.d.ts +44 -0
  89. package/dist/index.js +28 -0
  90. package/dist/inlay-hints.d.ts +13 -0
  91. package/dist/inlay-hints.js +71 -0
  92. package/dist/language-ids.d.ts +3 -0
  93. package/dist/language-ids.js +49 -0
  94. package/dist/line-edits.d.ts +8 -0
  95. package/dist/line-edits.js +39 -0
  96. package/dist/locales/en.json +171 -0
  97. package/dist/locales/nl.json +171 -0
  98. package/dist/models.d.ts +20 -0
  99. package/dist/models.js +20 -0
  100. package/dist/navigation-history.d.ts +16 -0
  101. package/dist/navigation-history.js +53 -0
  102. package/dist/navigation.d.ts +23 -0
  103. package/dist/navigation.js +155 -0
  104. package/dist/paths.d.ts +1 -0
  105. package/dist/paths.js +3 -0
  106. package/dist/peek-model.d.ts +28 -0
  107. package/dist/peek-model.js +104 -0
  108. package/dist/peek.d.ts +30 -0
  109. package/dist/peek.js +215 -0
  110. package/dist/pick.d.ts +34 -0
  111. package/dist/pick.js +166 -0
  112. package/dist/popup-placement.d.ts +23 -0
  113. package/dist/popup-placement.js +34 -0
  114. package/dist/popups.d.ts +133 -0
  115. package/dist/popups.js +14 -0
  116. package/dist/project-language.d.ts +36 -0
  117. package/dist/project-language.js +190 -0
  118. package/dist/project-problems.d.ts +24 -0
  119. package/dist/project-problems.js +63 -0
  120. package/dist/proposal-model.d.ts +36 -0
  121. package/dist/proposal-model.js +197 -0
  122. package/dist/recent-choices.d.ts +10 -0
  123. package/dist/recent-choices.js +39 -0
  124. package/dist/refresher.d.ts +13 -0
  125. package/dist/refresher.js +50 -0
  126. package/dist/relative-time.d.ts +1 -0
  127. package/dist/relative-time.js +9 -0
  128. package/dist/rename-model.d.ts +25 -0
  129. package/dist/rename-model.js +93 -0
  130. package/dist/rename.d.ts +21 -0
  131. package/dist/rename.js +257 -0
  132. package/dist/rendering-context.d.ts +6 -0
  133. package/dist/rendering-context.js +5 -0
  134. package/dist/rendering.d.ts +6 -0
  135. package/dist/rendering.js +5 -0
  136. package/dist/review-group.d.ts +16 -0
  137. package/dist/review-group.js +54 -0
  138. package/dist/row-host.d.ts +42 -0
  139. package/dist/row-host.js +120 -0
  140. package/dist/selection-ranges.d.ts +7 -0
  141. package/dist/selection-ranges.js +26 -0
  142. package/dist/semantic-model.d.ts +4 -0
  143. package/dist/semantic-model.js +76 -0
  144. package/dist/semantic-tokens.d.ts +6 -0
  145. package/dist/semantic-tokens.js +38 -0
  146. package/dist/shortcut-keys.d.ts +91 -0
  147. package/dist/shortcut-keys.js +12 -0
  148. package/dist/signature-model.d.ts +18 -0
  149. package/dist/signature-model.js +33 -0
  150. package/dist/signature.d.ts +22 -0
  151. package/dist/signature.js +150 -0
  152. package/dist/snippet-session.d.ts +17 -0
  153. package/dist/snippet-session.js +110 -0
  154. package/dist/snippet.d.ts +11 -0
  155. package/dist/snippet.js +134 -0
  156. package/dist/symbol-links.d.ts +6 -0
  157. package/dist/symbol-links.js +50 -0
  158. package/dist/symbol-picker-model.d.ts +44 -0
  159. package/dist/symbol-picker-model.js +146 -0
  160. package/dist/symbol-picker.d.ts +12 -0
  161. package/dist/symbol-picker.js +76 -0
  162. package/dist/symbols.d.ts +11 -0
  163. package/dist/symbols.js +95 -0
  164. package/dist/testing.d.ts +57 -0
  165. package/dist/testing.js +156 -0
  166. package/dist/timers.d.ts +14 -0
  167. package/dist/timers.js +34 -0
  168. package/dist/use-problem-counts.d.ts +3 -0
  169. package/dist/use-problem-counts.js +20 -0
  170. package/dist/view-state.d.ts +21 -0
  171. package/dist/view-state.js +63 -0
  172. package/dist/word-diff.d.ts +1 -0
  173. package/dist/word-diff.js +32 -0
  174. package/dist/workspace-edit.d.ts +21 -0
  175. package/dist/workspace-edit.js +147 -0
  176. package/package.json +74 -2
  177. package/src/AnchoredPopup.tsx +51 -0
  178. package/src/AttributionCard.tsx +36 -0
  179. package/src/BaselineStatus.tsx +20 -0
  180. package/src/ChangeReview.tsx +43 -0
  181. package/src/CodeAuthorsCard.tsx +81 -0
  182. package/src/CompletionPopup.tsx +196 -0
  183. package/src/EditorContextMenu.tsx +67 -0
  184. package/src/EditorView.tsx +47 -0
  185. package/src/FindReplace.tsx +89 -0
  186. package/src/HoverCard.tsx +121 -0
  187. package/src/HoverSections.tsx +146 -0
  188. package/src/LanguagePopups.tsx +65 -0
  189. package/src/Markdown.tsx +11 -0
  190. package/src/PathText.tsx +18 -0
  191. package/src/PeekPanel.tsx +222 -0
  192. package/src/PickPopup.tsx +102 -0
  193. package/src/RenameCard.tsx +219 -0
  194. package/src/SignatureCard.tsx +42 -0
  195. package/src/SymbolPicker.tsx +219 -0
  196. package/src/code-actions-model.ts +146 -0
  197. package/src/code-actions.ts +384 -0
  198. package/src/code-authors.ts +104 -0
  199. package/src/code-vision-model.ts +230 -0
  200. package/src/code-vision.ts +269 -0
  201. package/src/completion-call.ts +139 -0
  202. package/src/completion-model.ts +440 -0
  203. package/src/completion.ts +500 -0
  204. package/src/conflict-model.ts +56 -0
  205. package/src/context-menu.ts +63 -0
  206. package/src/definition-link.ts +106 -0
  207. package/src/diagnostics-model.ts +102 -0
  208. package/src/diagnostics.ts +117 -0
  209. package/src/editor-language.ts +189 -0
  210. package/src/editor-react.css +43 -0
  211. package/src/find-query.ts +74 -0
  212. package/src/folding.ts +110 -0
  213. package/src/highlight-layers.ts +37 -0
  214. package/src/highlights.ts +123 -0
  215. package/src/history.ts +56 -0
  216. package/src/holder.ts +19 -0
  217. package/src/host-types.ts +52 -0
  218. package/src/hover-content.ts +264 -0
  219. package/src/hover.ts +280 -0
  220. package/src/index.ts +63 -0
  221. package/src/inlay-hints.ts +85 -0
  222. package/src/language-ids.ts +54 -0
  223. package/src/line-edits.ts +48 -0
  224. package/src/locales/en.json +171 -0
  225. package/src/locales/nl.json +171 -0
  226. package/src/models.ts +157 -0
  227. package/src/navigation-history.ts +69 -0
  228. package/src/navigation.ts +168 -0
  229. package/src/paths.ts +3 -0
  230. package/src/peek-model.ts +147 -0
  231. package/src/peek.ts +247 -0
  232. package/src/pick.ts +197 -0
  233. package/src/popup-placement.ts +64 -0
  234. package/src/popups.ts +204 -0
  235. package/src/project-language.ts +223 -0
  236. package/src/project-problems.ts +87 -0
  237. package/src/proposal-model.ts +259 -0
  238. package/src/recent-choices.ts +48 -0
  239. package/src/refresher.ts +56 -0
  240. package/src/relative-time.ts +10 -0
  241. package/src/rename-model.ts +130 -0
  242. package/src/rename.ts +290 -0
  243. package/src/rendering-context.ts +12 -0
  244. package/src/rendering.tsx +6 -0
  245. package/src/review-group.ts +70 -0
  246. package/src/row-host.ts +158 -0
  247. package/src/selection-ranges.ts +32 -0
  248. package/src/semantic-model.ts +80 -0
  249. package/src/semantic-tokens.ts +46 -0
  250. package/src/shortcut-keys.ts +21 -0
  251. package/src/signature-model.ts +49 -0
  252. package/src/signature.ts +164 -0
  253. package/src/snippet-session.ts +122 -0
  254. package/src/snippet.ts +147 -0
  255. package/src/symbol-links.ts +72 -0
  256. package/src/symbol-picker-model.ts +198 -0
  257. package/src/symbol-picker.ts +85 -0
  258. package/src/symbols.ts +110 -0
  259. package/src/testing.ts +240 -0
  260. package/src/timers.ts +45 -0
  261. package/src/use-problem-counts.ts +25 -0
  262. package/src/view-state.ts +88 -0
  263. package/src/word-diff.ts +35 -0
  264. package/src/workspace-edit.ts +194 -0
@@ -0,0 +1,500 @@
1
+ import { StaleResultError, type CompletionContext, type CompletionItem } from '@adecore/lsp';
2
+ import type { EditorContentChange, EditorPosition, EditorRange, EditorTextChange } from '@adecore/editor';
3
+ import { comparePositions, shiftPosition } from './diagnostics-model.ts';
4
+ import type { EditorLanguage } from './editor-language.ts';
5
+ import {
6
+ completionDocsOf,
7
+ identifierPrefix,
8
+ insertionOf,
9
+ isIdentifierCharacter,
10
+ itemsOf,
11
+ matchedCharacters,
12
+ mirroredInsertions,
13
+ prefixFor,
14
+ qualifiersOf,
15
+ rankCompletions,
16
+ type Insertion,
17
+ type Ranked
18
+ } from './completion-model.ts';
19
+ import { PARAMETER_HINTS_COMMAND, isCallItem, planCall, withParentheses, type CallPlan } from './completion-call.ts';
20
+ import { RecentChoices, recentChoicesOf } from './recent-choices.ts';
21
+ import { shikiLanguageOf } from './language-ids.ts';
22
+ import { isShortcut } from './shortcut-keys.ts';
23
+ import type { CompletionRow, CompletionView } from './popups.ts';
24
+ import { realTimers, type Timers } from './timers.ts';
25
+
26
+ const METHOD = 'textDocument/completion';
27
+ const START_DELAY_MS = 80;
28
+ /* After typing stops, the list is asked for again at the new text: a server only resolves an item for the text it was made for. */
29
+ const REFRESH_DELAY_MS = 150;
30
+ const RESOLVE_DELAY_MS = 120;
31
+ const PAGE = 8;
32
+ const LINES_BACK = 60;
33
+ const TOAST_ID = 'language-completion';
34
+
35
+ function isEmpty(range: EditorRange): boolean {
36
+ return comparePositions(range.start, range.end) === 0;
37
+ }
38
+
39
+ /*
40
+ * The suggestions that open while a word is typed. A single typed identifier character or a trigger
41
+ * character of the server asks for the list; every character after that only filters the list that
42
+ * is already there, unless the server called it incomplete. Enter inserts, Tab replaces, Escape closes.
43
+ */
44
+ export class CompletionFeature {
45
+ private readonly language: EditorLanguage;
46
+ private readonly timers: Timers;
47
+ private pool: readonly CompletionItem[] = [];
48
+ private incomplete = false;
49
+ private ranked: readonly Ranked[] = [];
50
+ private active = 0;
51
+ /* Whether the person moved in the list; until then the active row is the best match, whatever the typing does to the list. */
52
+ private touched = false;
53
+ private activeKey: string | null = null;
54
+ /* Asked for with the keyboard, which is as good as moving in the list when a commit character is typed. */
55
+ private explicit = false;
56
+ private detailsOpen = true;
57
+ private start: EditorPosition | null = null;
58
+ private server = '';
59
+ private request = 0;
60
+ private controller: AbortController | null = null;
61
+ private requestTimer: unknown;
62
+ private refreshTimer: unknown;
63
+ private resolveTimer: unknown;
64
+ private readonly resolved = new Map<CompletionItem, CompletionItem>();
65
+
66
+ constructor(language: EditorLanguage, timers: Timers = realTimers) {
67
+ this.language = language;
68
+ this.timers = timers;
69
+ const { editor } = language;
70
+ const offs = [
71
+ editor.onKeyDown((event) => this.key(event)),
72
+ editor.onTextChange((change) => this.edited(change)),
73
+ editor.onCaret((position) => this.caretMoved(position)),
74
+ editor.onBlur(() => this.close())
75
+ ];
76
+ language.onDispose(() => {
77
+ for (const off of offs) {
78
+ off();
79
+ }
80
+ this.close();
81
+ });
82
+ }
83
+
84
+ get isOpen(): boolean {
85
+ return this.language.popups.getState().completion !== null;
86
+ }
87
+
88
+ /* Asks for suggestions at the caret now, as Ctrl+Space does. */
89
+ invoke(): void {
90
+ this.explicit = true;
91
+ this.beginRequest({ triggerKind: 1 }, 0);
92
+ }
93
+
94
+ move(delta: number): void {
95
+ if (this.ranked.length === 0) {
96
+ return;
97
+ }
98
+ const last = this.ranked.length - 1;
99
+ // One step past either end goes round, a page does not.
100
+ this.active = Math.abs(delta) === 1 ? (this.active + delta + last + 1) % (last + 1) : Math.max(0, Math.min(last, this.active + delta));
101
+ this.touched = true;
102
+ this.publish();
103
+ this.scheduleResolve();
104
+ }
105
+
106
+ /* Picks a row by a click or a key. */
107
+ async accept(replace: boolean, index = this.active, commit?: string): Promise<void> {
108
+ const entry = this.ranked[index];
109
+ if (entry === undefined) {
110
+ return;
111
+ }
112
+ const { editor, project, uri } = this.language;
113
+ recentChoicesOf(this.language.languageId).record(entry.item);
114
+ let item = this.resolved.get(entry.item) ?? entry.item;
115
+ const needsResolve = !this.resolved.has(entry.item) && project.service.providerOptions('completionItem/resolve', uri) !== undefined;
116
+ this.close();
117
+ if (needsResolve) {
118
+ // Resolving can add the import an item needs; an item that cannot be resolved is still inserted as it is.
119
+ item = await this.resolveNow(entry.item);
120
+ }
121
+ const caret = editor.getCaret();
122
+ const lineBefore = editor.textInRange({ start: { line: caret.line, character: 0 }, end: caret });
123
+ const lineAfter = editor.textInRange({ start: caret, end: { line: caret.line, character: Number.MAX_SAFE_INTEGER } });
124
+ // A character that commits is typed after the name, so a call is not added before it.
125
+ const planned = insertionOf(item, caret, lineBefore, replace, commit === undefined ? lineAfter : '(');
126
+ const { before, rest } = this.surroundings(planned, caret, lineAfter);
127
+ const plan: CallPlan =
128
+ commit === undefined ? planCall({ item, languageId: this.language.languageId, insertion: planned, before, rest, replace }) : { action: 'none' };
129
+ const main = plan.action === 'add' ? withParentheses(planned, plan.inside) : planned;
130
+ const extras = (item.additionalTextEdits ?? []).map((edit): EditorContentChange => ({ range: edit.range, text: edit.newText }));
131
+ // An import above the insertion moves it, and the tab stops are measured from where it ends up.
132
+ const start = extras
133
+ .filter((extra) => comparePositions(extra.range.end, main.range.start) <= 0)
134
+ .sort((left, right) => comparePositions(right.range.start, left.range.start))
135
+ .reduce((place, extra) => shiftPosition(place, extra), main.range.start);
136
+ // A name typed at several carets is completed at all of them, and the other carets end behind what was put in.
137
+ const carets = editor.getSelections();
138
+ const mirrored = mirroredInsertions(carets, editor.getSelection(), caret, (range) => editor.textInRange(range), main);
139
+ editor.applyEdits([{ range: main.range, text: main.text }, ...mirrored, ...extras]);
140
+ const landed = editor.getSelections();
141
+ if (commit === undefined) {
142
+ this.language.snippets.begin(start, main.text, main.stops);
143
+ if (plan.action === 'add' && plan.inside && mirrored.length > 0) {
144
+ this.placeMirroredCarets(carets, mirrored, landed);
145
+ }
146
+ if (plan.action === 'enter') {
147
+ const end = editor.getCaret();
148
+ editor.setCaret({ line: end.line, character: end.character + plan.shift });
149
+ }
150
+ } else {
151
+ const end = editor.getCaret();
152
+ editor.applyEdits([{ range: { start: end, end }, text: commit }]);
153
+ if (this.triggerCharacters().includes(commit)) {
154
+ this.beginRequest({ triggerKind: 2, triggerCharacter: commit }, 0);
155
+ }
156
+ }
157
+ this.afterInsert(item, commit, lineBefore);
158
+ editor.focus();
159
+ }
160
+
161
+ /* The carets that got the same `()` go inside it like the primary one, which the snippet already placed. */
162
+ private placeMirroredCarets(before: readonly EditorRange[], mirrored: readonly EditorContentChange[], landed: readonly EditorRange[]): void {
163
+ const { editor } = this.language;
164
+ const primary = before.length - 1;
165
+ const selections = landed.map((range, index): EditorRange => {
166
+ const caret = before[index]!.start;
167
+ const got = mirrored.some(
168
+ (edit) => edit.range.start.line === caret.line && edit.range.start.character <= caret.character && caret.character <= edit.range.end.character
169
+ );
170
+ if (index === primary || !got) {
171
+ return range;
172
+ }
173
+ const stop = { line: range.end.line, character: range.end.character - 1 };
174
+ return { start: stop, end: stop };
175
+ });
176
+ selections[primary] = editor.getSelection();
177
+ editor.setSelections(selections);
178
+ }
179
+
180
+ /* The lines above the insertion down to where it starts, and the rest of the line past what it replaces. */
181
+ private surroundings(insertion: Insertion, caret: EditorPosition, lineAfter: string): { before: string; rest: string } {
182
+ const { editor } = this.language;
183
+ const { start, end } = insertion.range;
184
+ const before = editor.textInRange({ start: { line: Math.max(0, start.line - LINES_BACK), character: 0 }, end: start });
185
+ const rest = end.line === caret.line ? lineAfter.slice(Math.max(0, end.character - caret.character)) : '';
186
+ return { before, rest };
187
+ }
188
+
189
+ /* Parameter info for a call that was opened (or that the server asks for), then the item's own command. */
190
+ private afterInsert(item: CompletionItem, commit: string | undefined, lineBefore: string): void {
191
+ const { editor, project, uri, signature } = this.language;
192
+ const { start } = editor.getSelection();
193
+ const insideCall = start.character > 0 && editor.textInRange({ start: { line: start.line, character: start.character - 1 }, end: start }) === '(';
194
+ if (commit === '(' || (insideCall && isCallItem(item, lineBefore))) {
195
+ signature.trigger();
196
+ }
197
+ const { command } = item;
198
+ if (command === undefined) {
199
+ return;
200
+ }
201
+ if (command.command === PARAMETER_HINTS_COMMAND) {
202
+ signature.trigger();
203
+ } else {
204
+ project.service.executeCommand(uri, command).catch(() => undefined);
205
+ }
206
+ }
207
+
208
+ /*
209
+ * Resolving gives the item its documentation and the import it needs, but only for the text it was
210
+ * made for. When the person typed on since, the list is asked for again at the new text and the same
211
+ * suggestion is resolved in that one; an item that cannot be resolved is still inserted as it is.
212
+ */
213
+ private async resolveNow(item: CompletionItem): Promise<CompletionItem> {
214
+ const { editor, project, uri } = this.language;
215
+ try {
216
+ return await project.service.resolveCompletion(uri, item);
217
+ } catch (error) {
218
+ if (!(error instanceof StaleResultError)) {
219
+ return item;
220
+ }
221
+ }
222
+ try {
223
+ const fresh = itemsOf(await project.service.completion(uri, editor.getCaret(), { triggerKind: 1 })).items.find(
224
+ (candidate) => candidate.label === item.label && candidate.kind === item.kind
225
+ );
226
+ return fresh === undefined ? item : await project.service.resolveCompletion(uri, fresh).catch(() => fresh);
227
+ } catch {
228
+ return item;
229
+ }
230
+ }
231
+
232
+ close(): void {
233
+ this.request++;
234
+ this.controller?.abort();
235
+ this.timers.clear(this.requestTimer);
236
+ this.timers.clear(this.refreshTimer);
237
+ this.timers.clear(this.resolveTimer);
238
+ this.pool = [];
239
+ this.ranked = [];
240
+ this.touched = false;
241
+ this.explicit = false;
242
+ this.activeKey = null;
243
+ this.start = null;
244
+ this.resolved.clear();
245
+ if (this.language.popups.getState().completion !== null) {
246
+ this.language.popups.setState({ completion: null });
247
+ }
248
+ }
249
+
250
+ private key(event: KeyboardEvent): boolean {
251
+ if (isShortcut(this.language.shortcuts.triggerCompletion, event)) {
252
+ if (this.isOpen) {
253
+ this.detailsOpen = !this.detailsOpen;
254
+ this.publish();
255
+ } else {
256
+ this.invoke();
257
+ }
258
+ return true;
259
+ }
260
+ if (!this.isOpen || event.ctrlKey || event.metaKey || event.altKey) {
261
+ return false;
262
+ }
263
+ if (this.commits(event)) {
264
+ void this.accept(false, this.active, event.key);
265
+ return true;
266
+ }
267
+ switch (event.key) {
268
+ case 'ArrowDown':
269
+ this.move(1);
270
+ return true;
271
+ case 'ArrowUp':
272
+ this.move(-1);
273
+ return true;
274
+ case 'PageDown':
275
+ this.move(PAGE);
276
+ return true;
277
+ case 'PageUp':
278
+ this.move(-PAGE);
279
+ return true;
280
+ case 'Enter':
281
+ case 'Tab':
282
+ if (event.shiftKey) {
283
+ return false;
284
+ }
285
+ void this.accept(event.key === 'Tab');
286
+ return true;
287
+ case 'Escape':
288
+ this.close();
289
+ return true;
290
+ default:
291
+ return false;
292
+ }
293
+ }
294
+
295
+ /* A character the active item commits on, typed once the person has moved in the list or asked for it, which is when Enter would not be the way they chose. */
296
+ private commits(event: KeyboardEvent): boolean {
297
+ if ((!this.touched && !this.explicit) || event.key.length !== 1 || event.isComposing) {
298
+ return false;
299
+ }
300
+ return this.ranked[this.active]?.item.commitCharacters?.includes(event.key) === true;
301
+ }
302
+
303
+ private edited(change: EditorTextChange): void {
304
+ if (change.source !== 'input' || change.changes.length !== 1) {
305
+ this.close();
306
+ return;
307
+ }
308
+ const { range, text } = change.changes[0]!;
309
+ const triggers = this.triggerCharacters();
310
+ const last = Array.from(text).at(-1) ?? '';
311
+ const typedIdentifier = text !== '' && Array.from(text).every(isIdentifierCharacter);
312
+ if (text.length > 0 && triggers.includes(last) && text.length === 1) {
313
+ this.close();
314
+ this.beginRequest({ triggerKind: 2, triggerCharacter: last }, 0);
315
+ } else if (this.isOpen) {
316
+ if (text === '' || typedIdentifier) {
317
+ this.refilter();
318
+ this.scheduleRefresh();
319
+ } else {
320
+ this.close();
321
+ }
322
+ } else if (text.length === 1 && typedIdentifier && isEmpty(range)) {
323
+ this.beginRequest({ triggerKind: 1 }, START_DELAY_MS);
324
+ }
325
+ }
326
+
327
+ private caretMoved(position: EditorPosition): void {
328
+ if (this.start === null) {
329
+ return;
330
+ }
331
+ if (position.line !== this.start.line || position.character < this.start.character) {
332
+ this.close();
333
+ } else {
334
+ this.refilter();
335
+ }
336
+ }
337
+
338
+ private triggerCharacters(): readonly string[] {
339
+ const { project, uri } = this.language;
340
+ return project.service.providerOptions(METHOD, uri)?.triggerCharacters ?? [];
341
+ }
342
+
343
+ private scheduleRefresh(): void {
344
+ const { project, uri } = this.language;
345
+ this.timers.clear(this.refreshTimer);
346
+ if (this.start === null || !project.service.supports(METHOD, uri)) {
347
+ return;
348
+ }
349
+ const request = this.request;
350
+ this.refreshTimer = this.timers.set(() => {
351
+ if (request === this.request && this.start !== null) {
352
+ void this.ask({ triggerKind: 1 }, request, true);
353
+ }
354
+ }, REFRESH_DELAY_MS);
355
+ }
356
+
357
+ private beginRequest(context: CompletionContext, delay: number): void {
358
+ const { project, uri } = this.language;
359
+ if (!project.service.supports(METHOD, uri)) {
360
+ return;
361
+ }
362
+ this.timers.clear(this.requestTimer);
363
+ const request = ++this.request;
364
+ this.requestTimer = this.timers.set(() => void this.ask(context, request), delay);
365
+ }
366
+
367
+ private async ask(context: CompletionContext, request: number, refresh = false): Promise<void> {
368
+ const { editor, project, uri } = this.language;
369
+ const position = editor.getCaret();
370
+ this.controller?.abort();
371
+ const controller = new AbortController();
372
+ this.controller = controller;
373
+ let result;
374
+ try {
375
+ result = await project.service.completion(uri, position, context, { signal: controller.signal });
376
+ } catch {
377
+ return;
378
+ }
379
+ if (request !== this.request) {
380
+ return;
381
+ }
382
+ const { items, incomplete } = itemsOf(result);
383
+ this.pool = items;
384
+ this.incomplete = incomplete;
385
+ this.resolved.clear();
386
+ this.server = project.host.serverNames?.(uri)[0] ?? '';
387
+ if (!refresh) {
388
+ const caret = editor.getCaret();
389
+ const lineBefore = editor.textInRange({ start: { line: caret.line, character: 0 }, end: caret });
390
+ const prefix = context.triggerKind === 2 ? '' : identifierPrefix(lineBefore);
391
+ this.start = { line: caret.line, character: caret.character - prefix.length };
392
+ this.active = 0;
393
+ this.touched = false;
394
+ } else if (this.touched && this.ranked[this.active] !== undefined) {
395
+ // The list is made again, so the row that was chosen is found again by what it says.
396
+ this.activeKey = RecentChoices.keyOf(this.ranked[this.active]!.item);
397
+ }
398
+ const asked = this.explicit && !refresh;
399
+ this.refilter();
400
+ if (asked && !this.isOpen && !this.incomplete) {
401
+ this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title: this.language.project.i18n.t('editor:language.completion.none') });
402
+ } else if (asked && this.ranked.length === 1 && !this.incomplete) {
403
+ // One answer to a list that was asked for is the one wanted.
404
+ void this.accept(false, 0);
405
+ }
406
+ }
407
+
408
+ private refilter(): void {
409
+ const { editor } = this.language;
410
+ const caret = editor.getCaret();
411
+ if (this.start === null || caret.line !== this.start.line || caret.character < this.start.character) {
412
+ this.close();
413
+ return;
414
+ }
415
+ const lineBefore = editor.textInRange({ start: { line: caret.line, character: 0 }, end: caret });
416
+ // Most items of a list replace the same range, so the text under it is read once and not once per item.
417
+ const typed = new Map<string, string>();
418
+ const textBetween = (range: EditorRange): string => {
419
+ const key = `${range.start.line}:${range.start.character}:${range.end.line}:${range.end.character}`;
420
+ let text = typed.get(key);
421
+ if (text === undefined) {
422
+ text = editor.textInRange(range);
423
+ typed.set(key, text);
424
+ }
425
+ return text;
426
+ };
427
+ const before = this.ranked[this.active]?.item;
428
+ const recent = recentChoicesOf(this.language.languageId);
429
+ this.ranked = rankCompletions(
430
+ this.pool,
431
+ (item) => prefixFor(item, caret, lineBefore, textBetween),
432
+ (item) => recent.recency(item)
433
+ );
434
+ if (this.ranked.length === 0) {
435
+ if (this.incomplete && this.pool.length > 0) {
436
+ this.beginRequest({ triggerKind: 3 }, START_DELAY_MS);
437
+ }
438
+ this.close();
439
+ return;
440
+ }
441
+ const key = this.activeKey ?? (this.touched && before !== undefined ? RecentChoices.keyOf(before) : null);
442
+ this.activeKey = null;
443
+ const kept = key === null ? -1 : this.ranked.findIndex((entry) => RecentChoices.keyOf(entry.item) === key);
444
+ this.active = kept >= 0 ? kept : 0;
445
+ this.touched = this.touched && kept >= 0;
446
+ this.publish();
447
+ this.scheduleResolve();
448
+ if (this.incomplete) {
449
+ this.beginRequest({ triggerKind: 3 }, START_DELAY_MS);
450
+ }
451
+ }
452
+
453
+ private scheduleResolve(): void {
454
+ const { project, uri } = this.language;
455
+ this.timers.clear(this.resolveTimer);
456
+ const entry = this.ranked[this.active];
457
+ if (entry === undefined || this.resolved.has(entry.item) || project.service.providerOptions('completionItem/resolve', uri) === undefined) {
458
+ return;
459
+ }
460
+ this.resolveTimer = this.timers.set(() => {
461
+ void project.service
462
+ .resolveCompletion(uri, entry.item)
463
+ .then((item) => {
464
+ this.resolved.set(entry.item, item);
465
+ if (this.ranked[this.active]?.item === entry.item) {
466
+ this.publish();
467
+ }
468
+ })
469
+ .catch(() => undefined);
470
+ }, RESOLVE_DELAY_MS);
471
+ }
472
+
473
+ private publish(): void {
474
+ const entry = this.ranked[this.active];
475
+ if (entry === undefined || this.start === null) {
476
+ return;
477
+ }
478
+ const item = this.resolved.get(entry.item) ?? entry.item;
479
+ const descriptions = qualifiersOf(this.ranked.map((ranked) => ranked.item));
480
+ const rows: CompletionRow[] = this.ranked.map(({ item: row, prefix }, index) => ({
481
+ label: row.label,
482
+ kind: row.kind,
483
+ detail: row.labelDetails?.detail ?? '',
484
+ description: descriptions[index]!,
485
+ matches: matchedCharacters(row.label, prefix),
486
+ deprecated: row.deprecated === true || row.tags?.includes(1) === true
487
+ }));
488
+ const highlightLanguage = shikiLanguageOf(this.language.languageId);
489
+ const view: CompletionView = {
490
+ anchor: this.start,
491
+ rows,
492
+ active: this.active,
493
+ detailsOpen: this.detailsOpen,
494
+ docs: completionDocsOf(item, highlightLanguage),
495
+ server: this.server,
496
+ highlightLanguage
497
+ };
498
+ this.language.popups.setState({ completion: view });
499
+ }
500
+ }
@@ -0,0 +1,56 @@
1
+ import { splitBlocks, splitLines, type MergeBlock } from '@adecore/merge';
2
+ import type { LineReplacement } from './line-edits.ts';
3
+
4
+ /* A stretch both sides changed, as the lines each has of it. */
5
+ export interface ConflictStretch {
6
+ base: readonly string[];
7
+ ours: readonly string[];
8
+ theirs: readonly string[];
9
+ /* Zero-based, `to` exclusive, in the text once everything that merges has merged. */
10
+ from: number;
11
+ to: number;
12
+ /* One-based and inclusive in the incoming text; `end` is below `start` when the other side removed the stretch. */
13
+ theirsStart: number;
14
+ theirsEnd: number;
15
+ }
16
+
17
+ export interface ConflictPlan {
18
+ /* What the other side changed alone, as replacements of the lines of our text. */
19
+ merges: LineReplacement[];
20
+ conflicts: ConflictStretch[];
21
+ }
22
+
23
+ /*
24
+ * Where two versions of a file that grew from one disagree. The stretches only the incoming text changed
25
+ * are ours to take without asking; only a stretch both sides changed needs a person.
26
+ */
27
+ export function planConflict(base: string, ours: string, theirs: string): ConflictPlan {
28
+ const blocks: MergeBlock[] = splitBlocks(splitLines(base), splitLines(ours), splitLines(theirs));
29
+ const merges: LineReplacement[] = [];
30
+ const conflicts: ConflictStretch[] = [];
31
+ let oursAt = 0;
32
+ let theirsAt = 0;
33
+ let mergedAt = 0;
34
+ for (const block of blocks) {
35
+ if (block.kind === 'theirs') {
36
+ merges.push({ from: oursAt, to: oursAt + block.ours.length, lines: block.theirs });
37
+ mergedAt += block.theirs.length;
38
+ } else {
39
+ if (block.kind === 'conflict') {
40
+ conflicts.push({
41
+ base: block.base,
42
+ ours: block.ours,
43
+ theirs: block.theirs,
44
+ from: mergedAt,
45
+ to: mergedAt + block.ours.length,
46
+ theirsStart: theirsAt + 1,
47
+ theirsEnd: theirsAt + block.theirs.length
48
+ });
49
+ }
50
+ mergedAt += block.ours.length;
51
+ }
52
+ oursAt += block.ours.length;
53
+ theirsAt += block.theirs.length;
54
+ }
55
+ return { merges, conflicts };
56
+ }
@@ -0,0 +1,63 @@
1
+ import type { EditorLanguage } from './editor-language.ts';
2
+
3
+ export type ClipboardAction = 'cut' | 'copy' | 'paste';
4
+
5
+ /*
6
+ * The editor's context menu. The editor only says where it was asked for; the menu is drawn by the page
7
+ * (`EditorContextMenu`) from what the language servers support at the caret, and the refactors it lists
8
+ * are asked for while it opens, so it never waits for them.
9
+ */
10
+ export class ContextMenuFeature {
11
+ private readonly language: EditorLanguage;
12
+ private request = 0;
13
+
14
+ constructor(language: EditorLanguage) {
15
+ this.language = language;
16
+ const off = language.editor.onContextMenu((menu) => {
17
+ // A right click outside the selection moves the caret there first, as every code editor does, so the menu is about what was clicked.
18
+ if (!menu.inSelection) {
19
+ language.editor.setCaret(menu.position);
20
+ }
21
+ this.open(menu.x, menu.y);
22
+ });
23
+ language.onDispose(() => {
24
+ off();
25
+ this.close();
26
+ });
27
+ }
28
+
29
+ close(): void {
30
+ this.request++;
31
+ if (this.language.popups.getState().menu !== null) {
32
+ this.language.popups.setState({ menu: null });
33
+ }
34
+ }
35
+
36
+ /* Cut, copy and paste go through the browser's own commands on the editor's input, which is what keeps the clipboard and the undo history one thing. */
37
+ clipboard(action: ClipboardAction): void {
38
+ const { editor } = this.language;
39
+ editor.focus();
40
+ const done = document.execCommand(action);
41
+ if (!done && action === 'paste') {
42
+ void navigator.clipboard
43
+ ?.readText()
44
+ .then((text) => {
45
+ const range = editor.getSelection();
46
+ editor.applyEdits([{ range, text }]);
47
+ })
48
+ .catch(() => undefined);
49
+ }
50
+ }
51
+
52
+ private open(x: number, y: number): void {
53
+ const request = ++this.request;
54
+ const { popups, codeActions } = this.language;
55
+ popups.setState({ menu: { x, y, refactors: [] } });
56
+ void codeActions.list(['refactor']).then((entries) => {
57
+ const shown = popups.getState().menu;
58
+ if (request === this.request && shown !== null && entries !== null) {
59
+ popups.setState({ menu: { ...shown, refactors: entries } });
60
+ }
61
+ });
62
+ }
63
+ }