@adecore/editor-react 0.23.1 → 0.24.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 (109) hide show
  1. package/dist/AnchoredPopup.d.ts +2 -1
  2. package/dist/AnchoredPopup.js +2 -2
  3. package/dist/CodeAuthorsCard.d.ts +4 -0
  4. package/dist/CodeAuthorsCard.js +8 -4
  5. package/dist/CompletionPopup.js +7 -3
  6. package/dist/EditorView.d.ts +8 -3
  7. package/dist/EditorView.js +79 -9
  8. package/dist/HoverCard.d.ts +3 -1
  9. package/dist/HoverCard.js +2 -2
  10. package/dist/LanguagePopups.d.ts +8 -4
  11. package/dist/LanguagePopups.js +4 -4
  12. package/dist/PickPopup.js +2 -1
  13. package/dist/RenameCard.js +10 -8
  14. package/dist/RenderedDocs.d.ts +10 -4
  15. package/dist/RenderedDocs.js +10 -6
  16. package/dist/call-hierarchy.js +15 -14
  17. package/dist/code-actions.js +5 -2
  18. package/dist/completion-model.js +3 -1
  19. package/dist/completion.d.ts +4 -2
  20. package/dist/completion.js +49 -27
  21. package/dist/diagnostics-model.d.ts +1 -0
  22. package/dist/diagnostics-model.js +1 -1
  23. package/dist/diagnostics.d.ts +1 -0
  24. package/dist/diagnostics.js +28 -10
  25. package/dist/diff-overlays.js +2 -2
  26. package/dist/editor-language.d.ts +3 -1
  27. package/dist/editor-language.js +10 -0
  28. package/dist/format-on-type.js +2 -3
  29. package/dist/highlights.js +3 -2
  30. package/dist/history.js +3 -2
  31. package/dist/holder.d.ts +3 -3
  32. package/dist/holder.js +2 -1
  33. package/dist/host-types.d.ts +3 -0
  34. package/dist/hover.d.ts +5 -0
  35. package/dist/hover.js +13 -5
  36. package/dist/index.d.ts +6 -2
  37. package/dist/index.js +3 -1
  38. package/dist/key-priority.d.ts +23 -0
  39. package/dist/key-priority.js +39 -0
  40. package/dist/locales/en.json +2 -1
  41. package/dist/locales/nl.json +2 -1
  42. package/dist/models.d.ts +2 -2
  43. package/dist/models.js +2 -2
  44. package/dist/navigation.js +3 -2
  45. package/dist/peek.js +3 -2
  46. package/dist/pick.js +2 -1
  47. package/dist/popup-focus.d.ts +5 -0
  48. package/dist/popup-focus.js +12 -0
  49. package/dist/popups.d.ts +1 -1
  50. package/dist/project-language.js +5 -4
  51. package/dist/project-problems.d.ts +10 -1
  52. package/dist/project-problems.js +34 -10
  53. package/dist/proposal-model.d.ts +0 -1
  54. package/dist/proposal-model.js +0 -8
  55. package/dist/rename-model.d.ts +2 -1
  56. package/dist/rename-model.js +57 -0
  57. package/dist/rename.d.ts +1 -0
  58. package/dist/rename.js +21 -5
  59. package/dist/rendered-docs.js +3 -2
  60. package/dist/signature.js +4 -3
  61. package/dist/snippet-session.d.ts +1 -1
  62. package/dist/snippet-session.js +8 -2
  63. package/dist/symbol-picker.js +3 -2
  64. package/package.json +6 -6
  65. package/src/AnchoredPopup.tsx +4 -2
  66. package/src/CodeAuthorsCard.tsx +21 -12
  67. package/src/CompletionPopup.tsx +11 -1
  68. package/src/EditorView.tsx +122 -11
  69. package/src/HoverCard.tsx +6 -1
  70. package/src/LanguagePopups.tsx +23 -4
  71. package/src/PickPopup.tsx +9 -1
  72. package/src/RenameCard.tsx +15 -9
  73. package/src/RenderedDocs.tsx +30 -8
  74. package/src/call-hierarchy.ts +15 -14
  75. package/src/code-actions.ts +5 -2
  76. package/src/completion-model.ts +3 -1
  77. package/src/completion.ts +48 -28
  78. package/src/diagnostics-model.ts +1 -1
  79. package/src/diagnostics.ts +29 -13
  80. package/src/diff-overlays.tsx +12 -8
  81. package/src/editor-language.ts +12 -1
  82. package/src/format-on-type.ts +2 -3
  83. package/src/highlights.ts +3 -2
  84. package/src/history.ts +3 -2
  85. package/src/holder.ts +5 -4
  86. package/src/host-types.ts +5 -0
  87. package/src/hover.ts +19 -5
  88. package/src/index.ts +6 -2
  89. package/src/key-priority.ts +49 -0
  90. package/src/locales/en.json +2 -1
  91. package/src/locales/nl.json +2 -1
  92. package/src/models.ts +1 -1
  93. package/src/navigation.ts +3 -2
  94. package/src/peek.ts +3 -2
  95. package/src/pick.ts +2 -1
  96. package/src/popup-focus.ts +17 -0
  97. package/src/popups.ts +1 -2
  98. package/src/project-language.ts +5 -4
  99. package/src/project-problems.ts +46 -11
  100. package/src/proposal-model.ts +0 -9
  101. package/src/rename-model.ts +60 -1
  102. package/src/rename.ts +21 -5
  103. package/src/rendered-docs.ts +3 -2
  104. package/src/signature.ts +4 -3
  105. package/src/snippet-session.ts +9 -3
  106. package/src/symbol-picker.ts +3 -2
  107. package/dist/relative-time.d.ts +0 -1
  108. package/dist/relative-time.js +0 -9
  109. package/src/relative-time.ts +0 -10
@@ -9,6 +9,7 @@ import { formatNumber } from '@adecore/ui/format';
9
9
  import { basenameOf, relativeTo } from './paths.ts';
10
10
  import { AnchoredPopup } from './AnchoredPopup.tsx';
11
11
  import type { EditorLanguage } from './editor-language.ts';
12
+ import { keepFocusOutOfList } from './popup-focus.ts';
12
13
  import type { RenameView } from './popups.ts';
13
14
 
14
15
  const CARD_ATTRIBUTE = 'data-rename-card';
@@ -22,10 +23,10 @@ function staysInside(target: EventTarget | null): boolean {
22
23
  * Suggested names from the host, in a list under the input; a press fills the input and the
23
24
  * typed name stays what Enter applies. While it is up the card says only its two keys.
24
25
  */
25
- function Suggestions({ names, active, onPick }: { names: readonly string[]; active: number; onPick(name: string): void }) {
26
+ function Suggestions({ names, active, onPick, onFocusBack }: { names: readonly string[]; active: number; onPick(name: string): void; onFocusBack(): void }) {
26
27
  const { t } = useTranslation('editor');
27
28
  return (
28
- <div className="flex w-[250px] flex-col p-1.25">
29
+ <div className="flex w-[250px] flex-col p-1.25" {...keepFocusOutOfList(onFocusBack)}>
29
30
  <div className="flex items-center gap-1.5 px-2.25 pt-1.25 pb-1 text-xs font-semibold text-text-faint">
30
31
  <Icon icon={Sparkles} size={12} className="shrink-0" />
31
32
  {t('language.rename.suggestions')}
@@ -50,7 +51,7 @@ function Suggestions({ names, active, onPick }: { names: readonly string[]; acti
50
51
  );
51
52
  }
52
53
 
53
- function Hints({ view, active, onPick }: { view: RenameView; active: number; onPick(name: string): void }) {
54
+ function Hints({ view, active, onPick, onFocusBack }: { view: RenameView; active: number; onPick(name: string): void; onFocusBack(): void }) {
54
55
  const { t } = useTranslation('editor');
55
56
  const { occurrences } = view;
56
57
  return (
@@ -61,7 +62,7 @@ function Hints({ view, active, onPick }: { view: RenameView; active: number; onP
61
62
  </div>
62
63
  )}
63
64
  {view.suggestions.length > 0 ? (
64
- <Suggestions names={view.suggestions} active={active} onPick={onPick} />
65
+ <Suggestions names={view.suggestions} active={active} onPick={onPick} onFocusBack={onFocusBack} />
65
66
  ) : (
66
67
  <div className="flex items-center gap-3 px-2.5 py-1.5 text-xs whitespace-nowrap text-text-muted">
67
68
  {occurrences !== null && (
@@ -208,12 +209,13 @@ export function RenameCard({ language, view, rect, endRect }: { language: Editor
208
209
  spellCheck={false}
209
210
  autoComplete="off"
210
211
  aria-label={view.original}
211
- className="fixed z-(--z-popup) rounded-sm border border-accent bg-surface px-1 font-mono text-code text-text shadow-(--float-shadow) outline-none [field-sizing:content]"
212
+ className="fixed z-(--z-popup) rounded-sm border border-accent bg-surface px-0 font-mono text-(length:--code-font-size) text-text shadow-(--float-shadow) outline-none [font-kerning:none] [field-sizing:content]"
212
213
  style={{
213
- left: Math.round(rect.left) - 5,
214
+ // Only the border stands beside the name: any padding would cover the character after it, often a `(`.
215
+ left: Math.round(rect.left) - 1,
214
216
  top: Math.round(rect.top) - 2,
215
217
  height: Math.round(rect.bottom - rect.top) + 4,
216
- minWidth: Math.round(symbolWidth) + 10
218
+ minWidth: Math.round(symbolWidth) + 2
217
219
  }}
218
220
  onChange={(event) => {
219
221
  setValue(event.target.value);
@@ -226,9 +228,13 @@ export function RenameCard({ language, view, rect, endRect }: { language: Editor
226
228
  }
227
229
  }}
228
230
  />
229
- <AnchoredPopup rect={{ ...rect, top: rect.top - 2, bottom: rect.bottom + 2 }} className="overflow-hidden" placement={{ gap: 2 }}>
231
+ <AnchoredPopup rect={{ ...rect, top: rect.top - 2, bottom: rect.bottom + 2 }} overflow="clip" placement={{ gap: 2 }}>
230
232
  <div {...{ [CARD_ATTRIBUTE]: '' }} onPointerDown={(event) => event.preventDefault()}>
231
- {view.phase === 'preview' ? <Preview language={language} view={view} /> : <Hints view={view} active={active} onPick={fill} />}
233
+ {view.phase === 'preview' ? (
234
+ <Preview language={language} view={view} />
235
+ ) : (
236
+ <Hints view={view} active={active} onPick={fill} onFocusBack={() => input.current?.focus()} />
237
+ )}
232
238
  </div>
233
239
  </AnchoredPopup>
234
240
  </>,
@@ -1,13 +1,13 @@
1
1
  import { createPortal } from 'react-dom';
2
- import { useSyncExternalStore, type MouseEvent } from 'react';
2
+ import { memo, useSyncExternalStore, type MouseEvent } from 'react';
3
+ import type { DocComment } from '@adecore/editor';
3
4
  import { useTranslation } from 'react-i18next';
4
5
  import type { EditorLanguage } from './editor-language.ts';
5
- import { DOC_LINK_PREFIX, sectionsOf, type DocTagEntry } from './doc-comment-model.ts';
6
+ import { DOC_LINK_PREFIX, sectionsOf, type DocTagEntry, type ParsedDocComment } from './doc-comment-model.ts';
6
7
  import { markdownParts } from './hover-content.ts';
7
8
  import { Signature } from './HoverSections.tsx';
8
9
  import { shikiLanguageOf } from './language-ids.ts';
9
10
  import { Markdown } from './Markdown.tsx';
10
- import type { RenderedDocEntry } from './rendered-docs.ts';
11
11
 
12
12
  const LINK = /\[([^\]]*)\]\(#doc-link:[^)]*\)/g;
13
13
  const KNOWN = new Set([
@@ -83,12 +83,18 @@ function TagRow({ tag, language, linkable, spaced }: { tag: DocTagEntry; languag
83
83
  );
84
84
  }
85
85
 
86
- export function RenderedDocBlock({ language, entry }: { language: EditorLanguage; entry: RenderedDocEntry }) {
86
+ interface DocBlockProps {
87
+ language: EditorLanguage;
88
+ comment: DocComment;
89
+ parsed: ParsedDocComment;
90
+ linkable: boolean;
91
+ }
92
+
93
+ function DocBlock({ language, comment, parsed, linkable }: DocBlockProps) {
87
94
  const { t } = useTranslation('editor');
88
95
  const feature = language.renderedDocs;
89
- const { description, tags } = entry.parsed;
96
+ const { description, tags } = parsed;
90
97
  const highlighter = shikiLanguageOf(language.languageId);
91
- const linkable = feature.canFollow;
92
98
  const sections = sectionsOf(tags);
93
99
  const deprecated = sections.find((section) => section.name === 'deprecated');
94
100
  const shown = sections.filter((section) => section.name !== 'deprecated');
@@ -106,7 +112,7 @@ export function RenderedDocBlock({ language, entry }: { language: EditorLanguage
106
112
  return;
107
113
  }
108
114
  if (href.startsWith(DOC_LINK_PREFIX)) {
109
- feature.follow(decodeURIComponent(href.slice(DOC_LINK_PREFIX.length)), entry.comment);
115
+ feature.follow(decodeURIComponent(href.slice(DOC_LINK_PREFIX.length)), comment);
110
116
  } else if (/^(https?:|mailto:)/i.test(href)) {
111
117
  window.open(href, '_blank', 'noopener,noreferrer');
112
118
  }
@@ -143,6 +149,9 @@ export function RenderedDocBlock({ language, entry }: { language: EditorLanguage
143
149
  );
144
150
  }
145
151
 
152
+ /* Drawn again only when its comment or its links change, since every scroll of the editor draws the popups again. */
153
+ export const RenderedDocBlock = memo(DocBlock);
154
+
146
155
  /*
147
156
  * The documentation comments the editor drew as rows, filled with their formatted text. It portals into
148
157
  * the element the editor made for each row, so the editor decides what is in view; a comment is parsed
@@ -151,5 +160,18 @@ export function RenderedDocBlock({ language, entry }: { language: EditorLanguage
151
160
  export function RenderedDocs({ language }: { language: EditorLanguage }) {
152
161
  const feature = language.renderedDocs;
153
162
  useSyncExternalStore(feature.subscribe, feature.getVersion, feature.getVersion);
154
- return <>{feature.entries().map((entry) => createPortal(<RenderedDocBlock language={language} entry={entry} />, entry.container, entry.id))}</>;
163
+ const linkable = feature.canFollow;
164
+ return (
165
+ <>
166
+ {feature
167
+ .entries()
168
+ .map((entry) =>
169
+ createPortal(
170
+ <RenderedDocBlock language={language} comment={entry.comment} parsed={entry.parsed} linkable={linkable} />,
171
+ entry.container,
172
+ entry.id
173
+ )
174
+ )}
175
+ </>
176
+ );
155
177
  }
@@ -1,4 +1,5 @@
1
1
  import { StaleResultError, type CallHierarchyItem } from '@adecore/lsp';
2
+ import { KEY_PRIORITY } from './key-priority.ts';
2
3
  import type { EditorPosition } from '@adecore/editor';
3
4
  import type { EditorLanguage } from './editor-language.ts';
4
5
  import {
@@ -53,7 +54,7 @@ export class CallHierarchyFeature {
53
54
  this.timers = timers;
54
55
  const { editor } = language;
55
56
  const offs = [
56
- editor.onKeyDown((event) => {
57
+ language.onKey((event) => {
57
58
  if (isShortcut(this.language.shortcuts.callHierarchy, event)) {
58
59
  if (this.isOpen) {
59
60
  this.setDirection(this.direction === 'incoming' ? 'outgoing' : 'incoming');
@@ -63,7 +64,7 @@ export class CallHierarchyFeature {
63
64
  return true;
64
65
  }
65
66
  return this.key(event);
66
- }),
67
+ }, KEY_PRIORITY.callHierarchy),
67
68
  editor.onTextChange(() => this.close())
68
69
  ];
69
70
  language.onDispose(() => {
@@ -75,7 +76,7 @@ export class CallHierarchyFeature {
75
76
  }
76
77
 
77
78
  get isOpen(): boolean {
78
- return (this.language.popups.getState().callHierarchy ?? null) !== null;
79
+ return this.language.popups.getState().callHierarchy !== null;
79
80
  }
80
81
 
81
82
  /* Whether a server of this document answers the three requests the tree is made of. */
@@ -137,7 +138,7 @@ export class CallHierarchyFeature {
137
138
 
138
139
  close(): void {
139
140
  this.reset();
140
- if (this.language.popups.getState().callHierarchy == null) {
141
+ if (this.language.popups.getState().callHierarchy === null) {
141
142
  return;
142
143
  }
143
144
  this.language.popups.setState({ callHierarchy: null });
@@ -148,7 +149,7 @@ export class CallHierarchyFeature {
148
149
 
149
150
  /* Starts the tree over from the same symbols for the other direction. */
150
151
  setDirection(direction: CallDirection): void {
151
- const view = this.language.popups.getState().callHierarchy ?? null;
152
+ const view = this.language.popups.getState().callHierarchy;
152
153
  if (view === null || view.direction === direction) {
153
154
  return;
154
155
  }
@@ -173,7 +174,7 @@ export class CallHierarchyFeature {
173
174
  }
174
175
  if (node.expanded) {
175
176
  this.patch(id, { expanded: false });
176
- const view = this.language.popups.getState().callHierarchy ?? null;
177
+ const view = this.language.popups.getState().callHierarchy;
177
178
  if (view !== null && this.hasAncestor(view.active, id)) {
178
179
  this.select(id);
179
180
  }
@@ -200,7 +201,7 @@ export class CallHierarchyFeature {
200
201
  }
201
202
  this.patch(parent, { shown: Math.min(node.children.length, node.shown + CALL_LEVEL_LIMIT) });
202
203
  const first = node.children[node.shown];
203
- const view = this.language.popups.getState().callHierarchy ?? null;
204
+ const view = this.language.popups.getState().callHierarchy;
204
205
  // The row that offered more is gone once everything shows, so the selection moves on to what it brought.
205
206
  if (first !== undefined && view?.active === moreIdOf(parent)) {
206
207
  this.select(first);
@@ -209,7 +210,7 @@ export class CallHierarchyFeature {
209
210
 
210
211
  /* Makes a row the active one and shows the code of its call. */
211
212
  select(id: string, site = 0): void {
212
- const view = this.language.popups.getState().callHierarchy ?? null;
213
+ const view = this.language.popups.getState().callHierarchy;
213
214
  const node = this.nodes.get(id);
214
215
  if (view === null || !view.rows.some((row) => row.id === id)) {
215
216
  return;
@@ -224,7 +225,7 @@ export class CallHierarchyFeature {
224
225
 
225
226
  /* Steps through the places a call is made from when the active one has more than one. */
226
227
  stepSite(direction: 1 | -1): void {
227
- const view = this.language.popups.getState().callHierarchy ?? null;
228
+ const view = this.language.popups.getState().callHierarchy;
228
229
  if (view !== null && view.sites > 1) {
229
230
  this.select(view.active, (view.site + direction + view.sites) % view.sites);
230
231
  }
@@ -232,7 +233,7 @@ export class CallHierarchyFeature {
232
233
 
233
234
  /* Goes to the call of a row, or to the symbol of a root, and closes the tree. */
234
235
  go(id: string): void {
235
- const view = this.language.popups.getState().callHierarchy ?? null;
236
+ const view = this.language.popups.getState().callHierarchy;
236
237
  const node = this.nodes.get(id);
237
238
  if (view === null || node === undefined) {
238
239
  return;
@@ -259,7 +260,7 @@ export class CallHierarchyFeature {
259
260
  }
260
261
 
261
262
  private mounted(container: HTMLElement): void {
262
- const view = this.language.popups.getState().callHierarchy ?? null;
263
+ const view = this.language.popups.getState().callHierarchy;
263
264
  if (view !== null && view.container !== container) {
264
265
  this.language.popups.setState({ callHierarchy: { ...view, container } });
265
266
  revealRow(this.timers, container);
@@ -268,7 +269,7 @@ export class CallHierarchyFeature {
268
269
 
269
270
  private patch(id: string, change: Partial<CallNode>): void {
270
271
  const node = this.nodes.get(id);
271
- const view = this.language.popups.getState().callHierarchy ?? null;
272
+ const view = this.language.popups.getState().callHierarchy;
272
273
  if (node === undefined || view === null) {
273
274
  return;
274
275
  }
@@ -329,7 +330,7 @@ export class CallHierarchyFeature {
329
330
  this.texts.set(uri, text);
330
331
  }
331
332
  }
332
- const view = this.language.popups.getState().callHierarchy ?? null;
333
+ const view = this.language.popups.getState().callHierarchy;
333
334
  if (view === null || view.active !== node.id) {
334
335
  return;
335
336
  }
@@ -354,7 +355,7 @@ export class CallHierarchyFeature {
354
355
  }
355
356
 
356
357
  private key(event: KeyboardEvent): boolean {
357
- const view = this.language.popups.getState().callHierarchy ?? null;
358
+ const view = this.language.popups.getState().callHierarchy;
358
359
  if (view === null || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.isComposing) {
359
360
  return false;
360
361
  }
@@ -1,4 +1,5 @@
1
1
  import { ErrorCodes, LspError, StaleResultError, type CodeAction, type Command, type Diagnostic, type Position } from '@adecore/lsp';
2
+ import { KEY_PRIORITY } from './key-priority.ts';
2
3
  import type { EditorPosition, EditorRange } from '@adecore/editor';
3
4
  import {
4
5
  ACTION_GROUPS,
@@ -79,11 +80,13 @@ export class CodeActionsFeature {
79
80
  const offs = [
80
81
  editor.onCaret(() => {
81
82
  editor.setGutterAction(null);
83
+ // An answer for the caret's last place would light the bulb on the line it moved to.
84
+ this.refresher.cancel();
82
85
  this.refresher.later();
83
86
  }),
84
87
  editor.onTextChange(() => editor.setGutterAction(null)),
85
88
  editor.onGutterAction(() => void this.open()),
86
- editor.onKeyDown((event) => {
89
+ language.onKey((event) => {
87
90
  if (isShortcut(this.language.shortcuts.codeActions, event)) {
88
91
  void this.open();
89
92
  return true;
@@ -93,7 +96,7 @@ export class CodeActionsFeature {
93
96
  return true;
94
97
  }
95
98
  return false;
96
- }),
99
+ }, KEY_PRIORITY.codeActions),
97
100
  language.diagnostics.onChange(() => this.refresher.later()),
98
101
  providers
99
102
  ];
@@ -1,3 +1,4 @@
1
+ import { labelCollator } from '@adecore/ui/format';
1
2
  import type { SnippetTransformJob } from './snippet-transform-protocol.ts';
2
3
  import type { EditorContentChange, EditorPosition, EditorRange } from '@adecore/editor';
3
4
  import { applyCompletionDefaults, type CompletionItem, type CompletionResult, type MarkupContent } from '@adecore/lsp';
@@ -201,6 +202,7 @@ export function rankCompletions(
201
202
  recency: (item: CompletionItem) => number = () => 0,
202
203
  limit = 150
203
204
  ): Ranked[] {
205
+ const { compare } = labelCollator();
204
206
  return items
205
207
  .map((item, index) => {
206
208
  const prefix = prefixOf(item);
@@ -212,7 +214,7 @@ export function rankCompletions(
212
214
  left.score - right.score ||
213
215
  right.recent - left.recent ||
214
216
  Number(right.item.preselect === true) - Number(left.item.preselect === true) ||
215
- (left.item.sortText ?? left.item.label).localeCompare(right.item.sortText ?? right.item.label) ||
217
+ compare(left.item.sortText ?? left.item.label, right.item.sortText ?? right.item.label) ||
216
218
  left.index - right.index
217
219
  )
218
220
  .slice(0, limit)
package/src/completion.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SnippetTransformQueueError } from './snippet-transform-queue.ts';
2
+ import { KEY_PRIORITY } from './key-priority.ts';
2
3
  import { SnippetTransformError } from './snippet-transform-client.ts';
3
4
  import { MAX_TRANSFORM_JOBS, MAX_TRANSFORM_WORK, type SnippetTransformJob } from './snippet-transform-protocol.ts';
4
5
  import { fileUriToPath, StaleResultError, type CompletionContext, type CompletionItem } from '@adecore/lsp';
@@ -32,11 +33,10 @@ import { isEmptyRange } from './proposal-model.ts';
32
33
  import { isShortcut } from './shortcut-keys.ts';
33
34
  import type { CompletionRow, CompletionView } from './popups.ts';
34
35
  import { realTimers, type Timers } from './timers.ts';
36
+ import { messageOf } from './error-message.ts';
35
37
 
36
38
  const METHOD = 'textDocument/completion';
37
39
  const START_DELAY_MS = 80;
38
- /* 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. */
39
- const REFRESH_DELAY_MS = 150;
40
40
  const RESOLVE_DELAY_MS = 120;
41
41
  const PAGE = 8;
42
42
  const LINES_BACK = 60;
@@ -103,8 +103,11 @@ export class CompletionFeature {
103
103
  private server = '';
104
104
  private request = 0;
105
105
  private controller: AbortController | null = null;
106
+ private resolving: AbortController | null = null;
107
+ private accepting: AbortController | null = null;
108
+ /* What the list was ranked for last: the caret event and the text event of one keystroke both refilter, and only the first has to rank. */
109
+ private rankedFor: { readonly pool: readonly CompletionItem[]; readonly key: string } | null = null;
106
110
  private requestTimer: unknown;
107
- private refreshTimer: unknown;
108
111
  private resolveTimer: unknown;
109
112
  private textRevision = 0;
110
113
  private acceptSequence = 0;
@@ -116,16 +119,18 @@ export class CompletionFeature {
116
119
  this.timers = timers;
117
120
  const { editor } = language;
118
121
  const offs = [
119
- editor.onKeyDown((event) => this.key(event)),
122
+ language.onKey((event) => this.key(event), KEY_PRIORITY.completion),
120
123
  editor.onTextChange((change) => this.edited(change)),
121
124
  editor.onCaret((position) => this.caretMoved(position)),
122
125
  editor.onBlur(() => {
123
126
  this.acceptSequence++;
127
+ this.accepting?.abort();
124
128
  this.close();
125
129
  })
126
130
  ];
127
131
  language.onDispose(() => {
128
132
  this.disposed = true;
133
+ this.accepting?.abort();
129
134
  for (const off of offs) {
130
135
  off();
131
136
  }
@@ -156,14 +161,26 @@ export class CompletionFeature {
156
161
  this.scheduleResolve();
157
162
  }
158
163
 
159
- /* Picks a row by a click or a key. */
164
+ /* Picks a row by a click or a key. It never rejects: a failure is told to the person, so a caller can leave the promise alone. */
160
165
  async accept(replace: boolean, index = this.active, commit?: string): Promise<void> {
166
+ try {
167
+ await this.insert(replace, index, commit);
168
+ } catch (error) {
169
+ const { project } = this.language;
170
+ project.host.notify?.({ id: TOAST_ID, kind: 'error', title: project.i18n.t('editor:language.completion.failed', { message: messageOf(error) }) });
171
+ }
172
+ }
173
+
174
+ private async insert(replace: boolean, index: number, commit: string | undefined): Promise<void> {
161
175
  const entry = this.ranked[index];
162
176
  if (entry === undefined) {
163
177
  return;
164
178
  }
165
179
  const { editor, project, uri, snippets } = this.language;
166
180
  const acceptSequence = ++this.acceptSequence;
181
+ this.accepting?.abort();
182
+ const accepting = new AbortController();
183
+ this.accepting = accepting;
167
184
  const snippetToken = snippets.token;
168
185
  const inSnippet = snippets.isActive;
169
186
  const exitChanges: { changes: readonly EditorContentChange[]; map: (range: EditorRange) => EditorRange }[] = [];
@@ -200,7 +217,7 @@ export class CompletionFeature {
200
217
  this.close();
201
218
  if (needsResolve) {
202
219
  // Resolving can add the import an item needs; an item that cannot be resolved is still inserted as it is.
203
- item = await this.resolveNow(entry.item);
220
+ item = await this.resolveNow(entry.item, accepting.signal);
204
221
  }
205
222
  if (!current()) {
206
223
  return;
@@ -464,20 +481,20 @@ export class CompletionFeature {
464
481
  * made for. When the person typed on since, the list is asked for again at the new text and the same
465
482
  * suggestion is resolved in that one; an item that cannot be resolved is still inserted as it is.
466
483
  */
467
- private async resolveNow(item: CompletionItem): Promise<CompletionItem> {
484
+ private async resolveNow(item: CompletionItem, signal: AbortSignal): Promise<CompletionItem> {
468
485
  const { editor, project, uri } = this.language;
469
486
  try {
470
- return { ...item, ...(await project.service.resolveCompletion(uri, item)) };
487
+ return { ...item, ...(await project.service.resolveCompletion(uri, item, { signal })) };
471
488
  } catch (error) {
472
489
  if (!(error instanceof StaleResultError)) {
473
490
  return item;
474
491
  }
475
492
  }
476
493
  try {
477
- const fresh = itemsOf(await project.service.completion(uri, editor.getCaret(), { triggerKind: 1 })).items.find(
494
+ const fresh = itemsOf(await project.service.completion(uri, editor.getCaret(), { triggerKind: 1 }, { signal })).items.find(
478
495
  (candidate) => candidate.label === item.label && candidate.kind === item.kind
479
496
  );
480
- return fresh === undefined ? item : { ...fresh, ...(await project.service.resolveCompletion(uri, fresh).catch(() => fresh)) };
497
+ return fresh === undefined ? item : { ...fresh, ...(await project.service.resolveCompletion(uri, fresh, { signal }).catch(() => fresh)) };
481
498
  } catch {
482
499
  return item;
483
500
  }
@@ -486,11 +503,12 @@ export class CompletionFeature {
486
503
  close(): void {
487
504
  this.request++;
488
505
  this.controller?.abort();
506
+ this.resolving?.abort();
489
507
  this.timers.clear(this.requestTimer);
490
- this.timers.clear(this.refreshTimer);
491
508
  this.timers.clear(this.resolveTimer);
492
509
  this.pool = [];
493
510
  this.ranked = [];
511
+ this.rankedFor = null;
494
512
  this.touched = false;
495
513
  this.explicit = false;
496
514
  this.activeKey = null;
@@ -573,12 +591,14 @@ export class CompletionFeature {
573
591
  } else if (this.isOpen) {
574
592
  if (text === '' || typedIdentifier) {
575
593
  this.refilter();
576
- this.scheduleRefresh();
577
594
  } else {
578
595
  this.close();
579
596
  }
580
597
  } else if (text.length === 1 && typedIdentifier && isEmptyRange(range)) {
581
598
  this.beginRequest({ triggerKind: 1 }, START_DELAY_MS);
599
+ } else if (text !== '') {
600
+ // A request the last letter started, still waiting or out, would open the list after a character that ends the word.
601
+ this.close();
582
602
  }
583
603
  }
584
604
 
@@ -598,20 +618,6 @@ export class CompletionFeature {
598
618
  return project.service.providerOptions(METHOD, uri)?.triggerCharacters ?? [];
599
619
  }
600
620
 
601
- private scheduleRefresh(): void {
602
- const { project, uri } = this.language;
603
- this.timers.clear(this.refreshTimer);
604
- if (this.start === null || !project.service.supports(METHOD, uri)) {
605
- return;
606
- }
607
- const request = this.request;
608
- this.refreshTimer = this.timers.set(() => {
609
- if (request === this.request && this.start !== null) {
610
- void this.ask({ triggerKind: 1 }, request, true);
611
- }
612
- }, REFRESH_DELAY_MS);
613
- }
614
-
615
621
  private beginRequest(context: CompletionContext, delay: number): void {
616
622
  const { project, uri } = this.language;
617
623
  if (!project.service.supports(METHOD, uri)) {
@@ -675,6 +681,11 @@ export class CompletionFeature {
675
681
  return;
676
682
  }
677
683
  const lineBefore = textBeforeOf(editor, caret);
684
+ const typedKey = `${caret.line}:${caret.character}:${lineBefore}`;
685
+ if (this.rankedFor?.pool === this.pool && this.rankedFor.key === typedKey) {
686
+ return;
687
+ }
688
+ this.rankedFor = { pool: this.pool, key: typedKey };
678
689
  // Most items of a list replace the same range, so the text under it is read once and not once per item.
679
690
  const typed = new Map<string, string>();
680
691
  const textBetween = (range: EditorRange): string => {
@@ -715,20 +726,29 @@ export class CompletionFeature {
715
726
  private scheduleResolve(): void {
716
727
  const { project, uri } = this.language;
717
728
  this.timers.clear(this.resolveTimer);
729
+ this.resolving?.abort();
718
730
  const entry = this.ranked[this.active];
719
731
  if (entry === undefined || this.resolved.has(entry.item) || project.service.providerOptions('completionItem/resolve', uri) === undefined) {
720
732
  return;
721
733
  }
734
+ const request = this.request;
722
735
  this.resolveTimer = this.timers.set(() => {
736
+ const controller = new AbortController();
737
+ this.resolving = controller;
723
738
  void project.service
724
- .resolveCompletion(uri, entry.item)
739
+ .resolveCompletion(uri, entry.item, { signal: controller.signal })
725
740
  .then((item) => {
726
741
  this.resolved.set(entry.item, { ...entry.item, ...item });
727
742
  if (this.ranked[this.active]?.item === entry.item) {
728
743
  this.publish();
729
744
  }
730
745
  })
731
- .catch(() => undefined);
746
+ .catch((error: unknown) => {
747
+ // A server only resolves an item for the text it was made for, so only an item that is wanted and stale costs a new list.
748
+ if (error instanceof StaleResultError && !controller.signal.aborted && request === this.request && this.start !== null) {
749
+ void this.ask({ triggerKind: 1 }, request, true);
750
+ }
751
+ });
732
752
  }, RESOLVE_DELAY_MS);
733
753
  }
734
754
 
@@ -41,7 +41,7 @@ export function comparePositions(left: EditorPosition, right: EditorPosition): n
41
41
  }
42
42
 
43
43
  /* Where an insertion of `text` at `start` ends, counting a line break of any kind once. */
44
- function endOfInsertion(start: EditorPosition, text: string): EditorPosition {
44
+ export function endOfInsertion(start: EditorPosition, text: string): EditorPosition {
45
45
  const lines = text.split(/\r\n|\r|\n/);
46
46
  return lines.length === 1
47
47
  ? { line: start.line, character: start.character + text.length }
@@ -1,6 +1,8 @@
1
1
  import type { Diagnostic } from '@adecore/lsp';
2
+ import { KEY_PRIORITY } from './key-priority.ts';
2
3
  import type { EditorContentChange, EditorPosition, EditorReveal } from '@adecore/editor';
3
4
  import type { EditorLanguage } from './editor-language.ts';
5
+ import { sameRange } from './hover-content.ts';
4
6
  import { isShortcut } from './shortcut-keys.ts';
5
7
  import { comparePositions, markerOf, neighborProblem, problemCountsOf, problemsAt, severityOf, shiftRange, type Problem } from './diagnostics-model.ts';
6
8
 
@@ -27,19 +29,19 @@ export class DiagnosticsFeature {
27
29
  const reports = project.service.onDiagnostics((report) => {
28
30
  if (report.uri === uri) {
29
31
  this.reports.set(report.source, report.diagnostics);
30
- this.rebuild(true);
32
+ this.rebuild();
31
33
  }
32
34
  });
33
35
  const edits = editor.onTextChange((change) => this.shift(change.changes));
34
36
  // These keys are the editor's while it has the keyboard; elsewhere the menu's commands answer.
35
- const keys = editor.onKeyDown((event) => {
37
+ const keys = language.onKey((event) => {
36
38
  const next = isShortcut(this.language.shortcuts.nextProblem, event);
37
39
  if (!next && !isShortcut(this.language.shortcuts.previousProblem, event)) {
38
40
  return false;
39
41
  }
40
42
  this.step(next ? 1 : -1);
41
43
  return true;
42
- });
44
+ }, KEY_PRIORITY.diagnostics);
43
45
  language.onDispose(() => {
44
46
  keys();
45
47
  reports.dispose();
@@ -99,23 +101,37 @@ export class DiagnosticsFeature {
99
101
  editor.announce(project.i18n.t('editor:language.announce.problem', { severity, message: problem.diagnostic.message }));
100
102
  }
101
103
 
104
+ /* The problems follow the edit as the markers do. Their number does not change, so nobody is told. */
102
105
  private shift(changes: readonly EditorContentChange[]): void {
106
+ let moved = false;
103
107
  for (const [server, diagnostics] of this.reports) {
104
- this.reports.set(
105
- server,
106
- diagnostics.map((diagnostic) => ({ ...diagnostic, range: shiftRange(diagnostic.range, changes) }))
107
- );
108
+ let shifted: Diagnostic[] | null = null;
109
+ for (const [index, diagnostic] of diagnostics.entries()) {
110
+ const range = shiftRange(diagnostic.range, changes);
111
+ if (!sameRange(range, diagnostic.range)) {
112
+ shifted ??= [...diagnostics];
113
+ shifted[index] = { ...diagnostic, range };
114
+ }
115
+ }
116
+ if (shifted !== null) {
117
+ this.reports.set(server, shifted);
118
+ moved = true;
119
+ }
120
+ }
121
+ if (moved) {
122
+ this.collect();
108
123
  }
109
- this.rebuild(false);
110
124
  }
111
125
 
112
- private rebuild(draw: boolean): void {
113
- this.list = [...this.reports].flatMap(([server, diagnostics]) => diagnostics.map((diagnostic): Problem => ({ diagnostic, server })));
114
- if (draw) {
115
- this.language.editor.setMarkers(this.list.map((problem) => markerOf(problem.diagnostic)));
116
- }
126
+ private rebuild(): void {
127
+ this.collect();
128
+ this.language.editor.setMarkers(this.list.map((problem) => markerOf(problem.diagnostic)));
117
129
  for (const listener of [...this.listeners]) {
118
130
  listener();
119
131
  }
120
132
  }
133
+
134
+ private collect(): void {
135
+ this.list = [...this.reports].flatMap(([server, diagnostics]) => diagnostics.map((diagnostic): Problem => ({ diagnostic, server })));
136
+ }
121
137
  }
@@ -1,7 +1,7 @@
1
1
  import { useSyncExternalStore } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { Button } from '@adecore/ui';
4
+ import { Button, Tooltip } from '@adecore/ui';
5
5
  import { formatNumber } from '@adecore/ui/format';
6
6
  import type { Editor, EditorEngine } from '@adecore/editor';
7
7
  import type { DiffDisplay } from '@adecore/merge';
@@ -38,13 +38,17 @@ export function DiffOverlays({
38
38
  const text = t('diff.hidden', { count: formatNumber(Math.max(row.oldCount, row.newCount)) });
39
39
  return createPortal(
40
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
- >
41
+ {row.unfoldable && blocked?.has(row.id) === true ? (
42
+ <Tooltip label={t('diff.contextLimit')}>
43
+ {/* A disabled button gets no pointer events, so the hint hangs on a wrapper. */}
44
+ <span className="inline-flex">
45
+ <Button size="xs" disabled>
46
+ {text}
47
+ </Button>
48
+ </span>
49
+ </Tooltip>
50
+ ) : row.unfoldable ? (
51
+ <Button size="xs" onClick={() => unfold(row.id)}>
48
52
  {text}
49
53
  </Button>
50
54
  ) : (