@adecore/editor-react 0.21.0 → 0.21.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 (109) hide show
  1. package/dist/AnchoredPopup.js +2 -3
  2. package/dist/HoverSections.js +5 -11
  3. package/dist/PeekPanel.js +6 -13
  4. package/dist/RenameCard.js +2 -2
  5. package/dist/SymbolPicker.js +2 -3
  6. package/dist/code-actions-model.js +9 -8
  7. package/dist/code-actions.d.ts +1 -1
  8. package/dist/code-actions.js +21 -20
  9. package/dist/code-authors.js +1 -1
  10. package/dist/code-vision.d.ts +1 -0
  11. package/dist/code-vision.js +17 -14
  12. package/dist/completion-call.js +2 -6
  13. package/dist/completion-model.d.ts +3 -2
  14. package/dist/completion-model.js +8 -9
  15. package/dist/completion.js +8 -10
  16. package/dist/context-menu.js +4 -5
  17. package/dist/definition-link.js +2 -1
  18. package/dist/diagnostics-model.d.ts +2 -0
  19. package/dist/diagnostics-model.js +8 -2
  20. package/dist/diagnostics.d.ts +1 -0
  21. package/dist/diagnostics.js +13 -12
  22. package/dist/editor-language.d.ts +2 -2
  23. package/dist/editor-language.js +10 -10
  24. package/dist/error-message.d.ts +1 -0
  25. package/dist/error-message.js +3 -0
  26. package/dist/folding.js +5 -24
  27. package/dist/history.d.ts +1 -0
  28. package/dist/history.js +9 -12
  29. package/dist/hover-content.d.ts +1 -0
  30. package/dist/hover-content.js +5 -4
  31. package/dist/hover.d.ts +2 -2
  32. package/dist/hover.js +10 -9
  33. package/dist/inlay-hints.js +4 -4
  34. package/dist/language-ids.js +1 -1
  35. package/dist/line-text.d.ts +3 -0
  36. package/dist/line-text.js +7 -0
  37. package/dist/navigation.js +9 -9
  38. package/dist/paths.d.ts +1 -0
  39. package/dist/paths.js +4 -0
  40. package/dist/peek.js +11 -4
  41. package/dist/project-language.js +15 -13
  42. package/dist/project-problems.d.ts +1 -2
  43. package/dist/project-problems.js +2 -4
  44. package/dist/refresher.js +11 -6
  45. package/dist/rename.d.ts +2 -2
  46. package/dist/rename.js +14 -12
  47. package/dist/review-group.d.ts +1 -0
  48. package/dist/review-group.js +10 -14
  49. package/dist/row-host.d.ts +13 -20
  50. package/dist/row-host.js +33 -60
  51. package/dist/semantic-model.js +4 -6
  52. package/dist/semantic-tokens.js +2 -4
  53. package/dist/signature.js +1 -1
  54. package/dist/snippet-session.js +3 -4
  55. package/dist/symbol-links.js +2 -2
  56. package/dist/symbol-picker-model.js +15 -9
  57. package/dist/symbol-picker.js +2 -1
  58. package/dist/symbols.d.ts +7 -1
  59. package/dist/symbols.js +22 -28
  60. package/dist/testing.js +3 -3
  61. package/dist/word-diff.js +2 -4
  62. package/dist/workspace-edit.js +7 -8
  63. package/package.json +5 -5
  64. package/src/AnchoredPopup.tsx +2 -3
  65. package/src/HoverSections.tsx +5 -11
  66. package/src/PeekPanel.tsx +6 -14
  67. package/src/RenameCard.tsx +2 -2
  68. package/src/SymbolPicker.tsx +3 -4
  69. package/src/code-actions-model.ts +10 -8
  70. package/src/code-actions.ts +36 -38
  71. package/src/code-authors.ts +2 -2
  72. package/src/code-vision.ts +19 -15
  73. package/src/completion-call.ts +2 -6
  74. package/src/completion-model.ts +10 -12
  75. package/src/completion.ts +8 -13
  76. package/src/context-menu.ts +4 -5
  77. package/src/definition-link.ts +2 -1
  78. package/src/diagnostics-model.ts +10 -2
  79. package/src/diagnostics.ts +15 -13
  80. package/src/editor-language.ts +11 -11
  81. package/src/error-message.ts +3 -0
  82. package/src/folding.ts +6 -26
  83. package/src/history.ts +11 -12
  84. package/src/hover-content.ts +6 -4
  85. package/src/hover.ts +10 -9
  86. package/src/inlay-hints.ts +4 -4
  87. package/src/language-ids.ts +1 -1
  88. package/src/line-text.ts +10 -0
  89. package/src/navigation.ts +11 -10
  90. package/src/paths.ts +5 -0
  91. package/src/peek.ts +11 -10
  92. package/src/project-language.ts +17 -16
  93. package/src/project-problems.ts +3 -6
  94. package/src/refresher.ts +12 -6
  95. package/src/rename.ts +16 -18
  96. package/src/review-group.ts +11 -15
  97. package/src/row-host.ts +54 -76
  98. package/src/semantic-model.ts +4 -6
  99. package/src/semantic-tokens.ts +2 -4
  100. package/src/signature.ts +1 -1
  101. package/src/snippet-session.ts +3 -4
  102. package/src/symbol-links.ts +2 -2
  103. package/src/symbol-picker-model.ts +17 -9
  104. package/src/symbol-picker.ts +2 -1
  105. package/src/symbols.ts +29 -28
  106. package/src/testing.ts +4 -2
  107. package/src/view-state.ts +2 -0
  108. package/src/word-diff.ts +2 -4
  109. package/src/workspace-edit.ts +7 -8
@@ -1,9 +1,9 @@
1
- import { useEditorRendering } from './rendering-context.ts';
2
1
  import { useEffect, useState, type MouseEvent } from 'react';
3
- import { Markdown } from './Markdown.tsx';
4
2
  import { Tooltip } from '@adecore/ui';
5
- import { hoverSectionsOf, markdownParts, type DocTag, type HoverSection, type HoverText } from './hover-content.ts';
6
3
  import { BaselineStatus } from './BaselineStatus.tsx';
4
+ import { hoverSectionsOf, markdownParts, type DocTag, type HoverSection, type HoverText } from './hover-content.ts';
5
+ import { Markdown } from './Markdown.tsx';
6
+ import { useEditorRendering } from './rendering-context.ts';
7
7
  import { declaredNameOf, linkTypeNames } from './symbol-links.ts';
8
8
 
9
9
  /* Code as the viewer colors it, plain until the grammar is in so the card never changes size under the pointer by much. */
@@ -102,10 +102,7 @@ function phpWithoutOpenTag(html: string): string {
102
102
  return html.replace(/<span[^>]*>&#x3C;\?<\/span><span[^>]*>php<\/span>(<span[^>]*>) /, '$1');
103
103
  }
104
104
 
105
- /*
106
- * Where a suggestion comes from, such as `use Raxos\Database\Orm\Attribute\PrimaryKey`, on one line that gives
107
- * way from its start, so the end of the name stays readable and a word is never cut in two. The full text is in the tooltip.
108
- */
105
+ /* Where a suggestion comes from, such as a PHP `use` line, cut from its start so the end of the name stays readable. */
109
106
  export function SourceLine({ text, language }: { text: string; language: string }) {
110
107
  const { theme, highlight } = useEditorRendering();
111
108
  const [html, setHtml] = useState<{ key: string; html: string } | null>(null);
@@ -113,10 +110,7 @@ export function SourceLine({ text, language }: { text: string; language: string
113
110
  const key = `${theme}\0${text}`;
114
111
 
115
112
  useEffect(() => {
116
- if (!highlighted) {
117
- return;
118
- }
119
- if (highlight === undefined) {
113
+ if (!highlighted || highlight === undefined) {
120
114
  return;
121
115
  }
122
116
  let alive = true;
package/src/PeekPanel.tsx CHANGED
@@ -1,4 +1,3 @@
1
- import { useEditorRendering } from './rendering-context.ts';
2
1
  import { useEffect, useMemo, useRef, useState } from 'react';
3
2
  import { createPortal } from 'react-dom';
4
3
  import { useTranslation } from 'react-i18next';
@@ -6,13 +5,14 @@ import { ChevronDown, ChevronRight, X } from 'lucide-react';
6
5
  import { fileUriToPath } from '@adecore/lsp';
7
6
  import { FileIcon, IconButton, Tooltip } from '@adecore/ui';
8
7
  import { formatNumber } from '@adecore/ui/format';
9
- import { basenameOf } from './paths.ts';
8
+ import { basenameOf, relativeTo } from './paths.ts';
10
9
  import type { EditorLanguage } from './editor-language.ts';
11
10
  import { shikiLanguageOfPath } from './language-ids.ts';
12
11
  import { PathText } from './PathText.tsx';
13
12
  import { PEEK_HEIGHT } from './peek.ts';
14
13
  import { distinguishingFolders, visualColumnOf, type NameRange, type PeekPlace } from './peek-model.ts';
15
14
  import type { PeekView } from './popups.ts';
15
+ import { useEditorRendering } from './rendering-context.ts';
16
16
 
17
17
  const TAB_SIZE = 4;
18
18
  const CODE =
@@ -28,10 +28,7 @@ function Preview({ view }: { view: PeekView }) {
28
28
  const key = preview === null ? '' : `${theme}\0${path}\0${preview.text}`;
29
29
 
30
30
  useEffect(() => {
31
- if (preview === null) {
32
- return;
33
- }
34
- if (highlight === undefined) {
31
+ if (preview === null || highlight === undefined) {
35
32
  return;
36
33
  }
37
34
  let alive = true;
@@ -98,10 +95,6 @@ function columnsOf(line: string, name: NameRange): NameRange {
98
95
  return { start, end: visualColumnOf(line, name.end, TAB_SIZE) };
99
96
  }
100
97
 
101
- function relativeTo(path: string, folder: string): string {
102
- return path.startsWith(`${folder}/`) ? path.slice(folder.length + 1) : path;
103
- }
104
-
105
98
  function folderOf(path: string): string {
106
99
  return path.slice(0, Math.max(0, path.lastIndexOf('/')));
107
100
  }
@@ -177,10 +170,9 @@ function Places({ language, view }: { language: EditorLanguage; view: PeekView }
177
170
  }
178
171
 
179
172
  /*
180
- * The references of a name drawn in the row the editor leaves under the line: the file of the active
181
- * reference and where it is, the code around it on the left and every place on the right. It lives in a
182
- * portal into the editor's own row, which the editor makes again when it scrolls back into view. It has
183
- * the editor's own ground and no edge of its own, so it reads as part of the file.
173
+ * The references of a name in the row the editor leaves under the line: the code around the active one on
174
+ * the left and every place on the right. It portals into that row, which the editor makes again when it
175
+ * scrolls back into view, and has no edge of its own, so it reads as part of the file.
184
176
  */
185
177
  export function PeekPanel({ language, view }: { language: EditorLanguage; view: PeekView }) {
186
178
  const { t } = useTranslation('editor');
@@ -6,7 +6,7 @@ import { fileUriToPath } from '@adecore/lsp';
6
6
  import type { EditorRect } from '@adecore/editor';
7
7
  import { Button, Icon } from '@adecore/ui';
8
8
  import { formatNumber } from '@adecore/ui/format';
9
- import { basenameOf } from './paths.ts';
9
+ import { basenameOf, relativeTo } from './paths.ts';
10
10
  import { AnchoredPopup } from './AnchoredPopup.tsx';
11
11
  import type { EditorLanguage } from './editor-language.ts';
12
12
  import type { RenameView } from './popups.ts';
@@ -91,7 +91,7 @@ function Preview({ language, view }: { language: EditorLanguage; view: RenameVie
91
91
  <div className="max-h-[320px] overflow-y-auto">
92
92
  {view.files.map((file) => {
93
93
  const path = fileUriToPath(file.uri);
94
- const shown = path === null ? file.uri : path.startsWith(`${folder}/`) ? path.slice(folder.length + 1) : path;
94
+ const shown = path === null ? file.uri : relativeTo(path, folder);
95
95
  return (
96
96
  <div key={file.uri} className="border-b border-border last:border-b-0">
97
97
  <div className="flex items-center gap-2 px-3 py-1.5 text-xs">
@@ -2,12 +2,11 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { AtSign, Hash } from 'lucide-react';
5
- import { Icon } from '@adecore/ui';
6
- import { formatShortcut } from '@adecore/ui';
7
- import { formatNumber } from '@adecore/ui/format';
8
- import { basenameOf } from './paths.ts';
9
5
  import { fileUriToPath } from '@adecore/lsp';
6
+ import { formatShortcut, Icon } from '@adecore/ui';
7
+ import { formatNumber } from '@adecore/ui/format';
10
8
  import type { EditorLanguage } from './editor-language.ts';
9
+ import { basenameOf } from './paths.ts';
11
10
  import type { SymbolsView } from './popups.ts';
12
11
  import {
13
12
  filterEntries,
@@ -17,6 +17,14 @@ export interface ActionEntry {
17
17
  readonly group: ActionGroup;
18
18
  }
19
19
 
20
+ /* Entries in the order of `order`, the preferred actions of each group first. */
21
+ function inGroupOrder(entries: readonly ActionEntry[], order: readonly ActionGroup[]): ActionEntry[] {
22
+ return order.flatMap((group) => {
23
+ const own = entries.filter((entry) => entry.group === group);
24
+ return [...own.filter((entry) => entry.action.isPreferred === true), ...own.filter((entry) => entry.action.isPreferred !== true)];
25
+ });
26
+ }
27
+
20
28
  /* The kinds nest by dots: `refactor.extract.function` is an extraction and `source.organizeImports` a source action. */
21
29
  export function groupOf(kind: string | undefined): ActionGroup {
22
30
  if (kind === 'quickfix' || kind?.startsWith('quickfix.')) {
@@ -45,10 +53,7 @@ export function actionsOf(result: readonly (CodeAction | Command)[] | null, orde
45
53
  .map((item) => (typeof item.command === 'string' ? ({ title: item.title, command: item as Command } satisfies CodeAction) : (item as CodeAction)))
46
54
  .filter((action) => action.disabled === undefined)
47
55
  .map((action, index) => ({ id: String(index), action, group: groupOf(action.kind) }));
48
- return order.flatMap((group) => {
49
- const own = entries.filter((entry) => entry.group === group);
50
- return [...own.filter((entry) => entry.action.isPreferred === true), ...own.filter((entry) => entry.action.isPreferred !== true)];
51
- });
56
+ return inGroupOrder(entries, order);
52
57
  }
53
58
 
54
59
  /* Entries from several requests as one list: the same order as a single answer, and an id of its own for each row. Two actions with a title and kind alike are one. */
@@ -62,10 +67,7 @@ export function mergeEntries(lists: readonly (readonly ActionEntry[])[]): Action
62
67
  seen.add(key);
63
68
  return true;
64
69
  });
65
- return ACTION_GROUPS.flatMap((group) => {
66
- const own = unique.filter((entry) => entry.group === group);
67
- return [...own.filter((entry) => entry.action.isPreferred === true), ...own.filter((entry) => entry.action.isPreferred !== true)];
68
- }).map((entry, index) => ({ ...entry, id: String(index) }));
70
+ return inGroupOrder(unique, ACTION_GROUPS).map((entry, index) => ({ ...entry, id: String(index) }));
69
71
  }
70
72
 
71
73
  /* Whether the lightbulb offers it: a source action such as organizing imports is a command of its own and never a hint. */
@@ -18,6 +18,7 @@ import type { PickGroup, PickPreview } from './popups.ts';
18
18
  import { Refresher } from './refresher.ts';
19
19
  import { isShortcut } from './shortcut-keys.ts';
20
20
  import { realTimers, type Timers } from './timers.ts';
21
+ import { messageOf } from './error-message.ts';
21
22
 
22
23
  const METHOD = 'textDocument/codeAction';
23
24
  const PAUSE_MS = 250;
@@ -36,6 +37,14 @@ function isRefusal(error: unknown): boolean {
36
37
  return error instanceof LspError && error.code === ErrorCodes.InternalError;
37
38
  }
38
39
 
40
+ interface RequestOptions {
41
+ readonly only?: readonly string[];
42
+ readonly signal?: AbortSignal;
43
+ /* The problems the actions are about; the ones touching the range when absent. */
44
+ readonly about?: readonly Diagnostic[];
45
+ readonly order?: readonly ActionGroup[];
46
+ }
47
+
39
48
  interface AskOptions {
40
49
  readonly range: EditorRange;
41
50
  readonly only?: readonly string[];
@@ -49,16 +58,11 @@ interface AskOptions {
49
58
  }
50
59
 
51
60
  /*
52
- * What the language servers offer to do at the caret or the selection: a lightbulb in the gutter on the line
53
- * that has something, a list under the caret (Mod+.) grouped by kind with the change an action would make
54
- * shown under it, the Quick fix of a problem's card, and the commands that organize imports and format the file.
55
- * An action is resolved before it is applied, since a server fills in its edit only when asked for it.
61
+ * What the servers offer at the caret or the selection: the gutter's lightbulb, the list under the caret
62
+ * with a preview of each change, a problem's Quick fix, organize imports and format. An action is resolved
63
+ * before it is applied, since a server fills in its edit only when asked for it.
56
64
  */
57
65
  export class CodeActionsFeature {
58
- private tell(kind: 'success' | 'error', title: string): void {
59
- this.language.project.host.notify?.({ id: TOAST_ID, kind, title });
60
- }
61
-
62
66
  private readonly language: EditorLanguage;
63
67
  private readonly refresher: Refresher;
64
68
  /* While a list is being asked for, the lightbulb waits: both are the same request and the newer one would cancel the older. */
@@ -127,7 +131,7 @@ export class CodeActionsFeature {
127
131
  async organizeImports(): Promise<void> {
128
132
  const { editor } = this.language;
129
133
  const everything = { start: { line: 0, character: 0 }, end: editor.positionAt(Number.MAX_SAFE_INTEGER) };
130
- const entries = await this.request(everything, ['source.organizeImports'], INVOKED);
134
+ const entries = await this.request(everything, INVOKED, { only: ['source.organizeImports'] });
131
135
  const entry = entries?.find((candidate) => candidate.action.kind?.startsWith('source.organizeImports') === true);
132
136
  if (entry === undefined) {
133
137
  this.tell('success', this.say(entries === null ? 'unavailable' : 'organized'));
@@ -154,16 +158,10 @@ export class CodeActionsFeature {
154
158
 
155
159
  /* The actions of one kind for the selection, as entries a menu can list and `apply` runs. */
156
160
  list(only: readonly string[]): Promise<ActionEntry[] | null> {
157
- return this.supported
158
- ? this.request(
159
- this.language.editor.getSelection(),
160
- only,
161
- AUTOMATIC,
162
- undefined,
163
- undefined,
164
- only.includes('refactor') ? REFACTOR_GROUPS : ACTION_GROUPS
165
- )
166
- : Promise.resolve(null);
161
+ if (!this.supported) {
162
+ return Promise.resolve(null);
163
+ }
164
+ return this.request(this.language.editor.getSelection(), AUTOMATIC, { only, order: only.includes('refactor') ? REFACTOR_GROUPS : ACTION_GROUPS });
167
165
  }
168
166
 
169
167
  private async ask(options: AskOptions): Promise<void> {
@@ -178,7 +176,7 @@ export class CodeActionsFeature {
178
176
  try {
179
177
  entries = options.withLine
180
178
  ? await this.requestWithLine(options.range, INVOKED)
181
- : await this.request(options.range, options.only, INVOKED, undefined, options.diagnostics, options.groups);
179
+ : await this.request(options.range, INVOKED, { only: options.only, about: options.diagnostics, order: options.groups });
182
180
  } finally {
183
181
  this.asking = false;
184
182
  }
@@ -210,24 +208,18 @@ export class CodeActionsFeature {
210
208
  }
211
209
 
212
210
  /* Asks the servers; null when they could not answer, with the reason said aloud for a request a person made. */
213
- private async request(
214
- range: EditorRange,
215
- only: readonly string[] | undefined,
216
- triggerKind: number,
217
- signal?: AbortSignal,
218
- about?: readonly Diagnostic[],
219
- order?: readonly ActionGroup[]
220
- ): Promise<ActionEntry[] | null> {
211
+ private async request(range: EditorRange, triggerKind: number, options: RequestOptions = {}): Promise<ActionEntry[] | null> {
221
212
  const { project, uri, diagnostics } = this.language;
213
+ const { only, signal, order } = options;
222
214
  try {
215
+ const about =
216
+ options.about ??
217
+ diagnosticsAt(
218
+ diagnostics.problems.map((problem) => problem.diagnostic),
219
+ range
220
+ );
223
221
  const context = {
224
- diagnostics: [
225
- ...(about ??
226
- diagnosticsAt(
227
- diagnostics.problems.map((problem) => problem.diagnostic),
228
- range
229
- ))
230
- ],
222
+ diagnostics: [...about],
231
223
  ...(only ? { only: [...only] } : {}),
232
224
  triggerKind: triggerKind as 1 | 2
233
225
  };
@@ -247,7 +239,7 @@ export class CodeActionsFeature {
247
239
  * elsewhere on its line too, since the fix of an error is wanted from anywhere on the line it is on.
248
240
  */
249
241
  private async requestWithLine(range: EditorRange, triggerKind: number, signal?: AbortSignal): Promise<ActionEntry[] | null> {
250
- const entries = await this.request(range, undefined, triggerKind, signal);
242
+ const entries = await this.request(range, triggerKind, { signal });
251
243
  if (entries === null || range.start.line !== range.end.line) {
252
244
  return entries;
253
245
  }
@@ -256,7 +248,9 @@ export class CodeActionsFeature {
256
248
  return entries;
257
249
  }
258
250
  const onLine = fixableOnLine(all, range.start.line).slice(0, LINE_PROBLEMS);
259
- const fixes = await Promise.all(onLine.map((diagnostic) => this.request(diagnostic.range, ['quickfix'], triggerKind, signal, [diagnostic])));
251
+ const fixes = await Promise.all(
252
+ onLine.map((diagnostic) => this.request(diagnostic.range, triggerKind, { only: ['quickfix'], signal, about: [diagnostic] }))
253
+ );
260
254
  return mergeEntries([entries, ...fixes.map((list) => list ?? [])]);
261
255
  }
262
256
 
@@ -372,9 +366,13 @@ export class CodeActionsFeature {
372
366
  }
373
367
  }
374
368
 
369
+ private tell(kind: 'success' | 'error', title: string): void {
370
+ this.language.project.host.notify?.({ id: TOAST_ID, kind, title });
371
+ }
372
+
375
373
  private fail(error: unknown): void {
376
374
  if (!(error instanceof StaleResultError)) {
377
- this.tell('error', this.say('failed', { message: error instanceof Error ? error.message : String(error) }));
375
+ this.tell('error', this.say('failed', { message: messageOf(error) }));
378
376
  }
379
377
  }
380
378
 
@@ -1,9 +1,9 @@
1
1
  import type { i18n } from 'i18next';
2
2
  import { diffLines, splitLines } from '@adecore/merge';
3
- import type { GitBlameCommit, GitBlameResult } from './host-types.ts';
4
3
  import { formatNumber } from '@adecore/ui/format';
4
+ import type { GitBlameCommit, GitBlameResult } from './host-types.ts';
5
5
 
6
- /* What a line of the text in the editor is: the index of the commit that wrote it, or this for a line no commit holds yet. */
6
+ /* A line no commit holds yet; every other line maps to the index of the commit that wrote it. */
7
7
  export const UNCOMMITTED = -1;
8
8
 
9
9
  /*
@@ -1,5 +1,5 @@
1
1
  import { splitLines } from '@adecore/merge';
2
- import type { GitBlameResult } from './host-types.ts';
2
+ import type { GitBlameCommit, GitBlameResult } from './host-types.ts';
3
3
  import type { EditorCodeVision, EditorCodeVisionEntry, EditorRect } from '@adecore/editor';
4
4
  import type { DocumentSymbolResult } from '@adecore/lsp';
5
5
  import type { EditorLanguage } from './editor-language.ts';
@@ -30,11 +30,10 @@ export type BlameSource =
30
30
  | { readonly kind: 'ready'; readonly blame: GitBlameResult; readonly base: string; openCommit?(hash: string): void };
31
31
 
32
32
  /*
33
- * The rows above declarations: how often each is used and, once git has been asked, who wrote it. The
34
- * declarations come from the symbols the servers know, and a row holds its height from the first
35
- * answer on, empty until its entries are in, so the text never moves when they arrive. Counts are asked
36
- * lazily, for the declarations in view and a margin around them, a few at a time, and only after the
37
- * text has settled; an old count stays on its row until the new one is in.
33
+ * The rows above declarations: how often each is used and who wrote it. A row holds its height from the
34
+ * first answer on, empty until its entries are in, so the text never moves when they arrive. Counts are
35
+ * asked for the declarations near the view, a few at a time, once the text has settled; an old count
36
+ * stays on its row until the new one is in.
38
37
  */
39
38
  export class CodeVisionFeature {
40
39
  private readonly language: EditorLanguage;
@@ -214,17 +213,12 @@ export class CodeVisionFeature {
214
213
  });
215
214
  }
216
215
  if (mapped !== null && declaration.authors) {
217
- let authorship = this.authorships.get(declaration.id);
218
- if (authorship === undefined) {
219
- authorship = authorshipOf(commits, mapped, this.lines, declaration.authorFrom, declaration.authorTo);
220
- this.authorships.set(declaration.id, authorship);
221
- }
222
- const shown = authorship;
216
+ const authorship = this.authorshipOf(declaration, commits, mapped);
223
217
  entries.push({
224
218
  id: 'authors',
225
- text: authorsText(shown, this.language.project.i18n),
226
- ...(shown.authors.length > 0 ? { icon: shown.authors.length > 1 ? ('users' as const) : ('user' as const) } : {}),
227
- activate: (anchor) => this.showAuthors(declaration, shown, anchor)
219
+ text: authorsText(authorship, this.language.project.i18n),
220
+ ...(authorship.authors.length > 0 ? { icon: authorship.authors.length > 1 ? ('users' as const) : ('user' as const) } : {}),
221
+ activate: (anchor) => this.showAuthors(declaration, authorship, anchor)
228
222
  });
229
223
  }
230
224
  rows.push({ id: declaration.id, line: declaration.line, entries });
@@ -232,6 +226,16 @@ export class CodeVisionFeature {
232
226
  return rows;
233
227
  }
234
228
 
229
+ private authorshipOf(declaration: CodeVisionDeclaration, commits: readonly GitBlameCommit[], mapped: Int32Array): CodeAuthorship {
230
+ const known = this.authorships.get(declaration.id);
231
+ if (known !== undefined) {
232
+ return known;
233
+ }
234
+ const authorship = authorshipOf(commits, mapped, this.lines, declaration.authorFrom, declaration.authorTo);
235
+ this.authorships.set(declaration.id, authorship);
236
+ return authorship;
237
+ }
238
+
235
239
  private mapOfBlame(source: Extract<BlameSource, { kind: 'ready' }>): Int32Array | null {
236
240
  if (this.mapped?.blame !== source.blame) {
237
241
  this.mapped = { blame: source.blame, lines: mapBlame(source.blame, source.base, this.lines) };
@@ -1,5 +1,5 @@
1
1
  import type { CompletionItem } from '@adecore/lsp';
2
- import { CLASS_KIND, CONSTRUCTOR_KIND, FUNCTION_KIND, METHOD_KIND, SNIPPET_FORMAT, type Insertion } from './completion-model.ts';
2
+ import { CLASS_KIND, SNIPPET_FORMAT, SOURCE_LINE, isCallableKind, type Insertion } from './completion-model.ts';
3
3
 
4
4
  /* What the platform's editors send to ask for parameter info once an item is in; the card is ours to open. */
5
5
  export const PARAMETER_HINTS_COMMAND = 'editor.action.triggerParameterHints';
@@ -13,7 +13,6 @@ const TYPE_OPERATOR = /\b(?:typeof|keyof|instanceof|implements|satisfies|as)\s+[
13
13
  const AFTER_NEW = /\bnew\s+[\w$.\\]*$/;
14
14
  const EXISTING_CALL = /^\s*\(/;
15
15
  const SIGNATURE_KIND_PREFIX = /^\([a-z]+(?: [a-z]+)*\)\s+(?!=>)/;
16
- const SOURCE_LINE = /^(?:use\s|Auto import from\b)/;
17
16
 
18
17
  /*
19
18
  * What accepting an item does about the call it names: nothing, parentheses added with the caret inside or
@@ -37,10 +36,7 @@ export interface CallSite {
37
36
 
38
37
  /* Whether the item is something that is called where it stands: a function, a method, a constructor, or a class after `new`. */
39
38
  export function isCallItem(item: CompletionItem, lineBefore: string): boolean {
40
- if (item.kind === METHOD_KIND || item.kind === FUNCTION_KIND || item.kind === CONSTRUCTOR_KIND) {
41
- return true;
42
- }
43
- return item.kind === CLASS_KIND && AFTER_NEW.test(lineBefore);
39
+ return isCallableKind(item.kind) || (item.kind === CLASS_KIND && AFTER_NEW.test(lineBefore));
44
40
  }
45
41
 
46
42
  /* Whether the parameter list in a signature has anything in it; null when the text names no list. */
@@ -1,7 +1,7 @@
1
1
  import type { EditorContentChange, EditorPosition, EditorRange } from '@adecore/editor';
2
- import type { CompletionItem, CompletionList, CompletionResult, InsertReplaceEdit, MarkupContent, TextEdit } from '@adecore/lsp';
2
+ import type { CompletionItem, CompletionList, CompletionResult, MarkupContent } from '@adecore/lsp';
3
3
  import { comparePositions } from './diagnostics-model.ts';
4
- import { isEmptyHover, splitSignatures, type HoverText } from './hover-content.ts';
4
+ import { escapeMarkdown, isEmptyHover, splitSignatures, type HoverText } from './hover-content.ts';
5
5
  import type { CompletionDocs } from './popups.ts';
6
6
  import { parseSnippet, tabOrder, type SnippetStop } from './snippet.ts';
7
7
 
@@ -280,6 +280,10 @@ export const CONSTRUCTOR_KIND = 4;
280
280
  export const CLASS_KIND = 7;
281
281
  export const SNIPPET_FORMAT = 2;
282
282
 
283
+ export function isCallableKind(kind: number | undefined): boolean {
284
+ return kind === METHOD_KIND || kind === FUNCTION_KIND || kind === CONSTRUCTOR_KIND;
285
+ }
286
+
283
287
  /* A call snippet with the call cut off, for a name that already has its parentheses: `log(${1:value})$0` becomes `log`. */
284
288
  function withoutCall(snippet: string): string {
285
289
  const open = snippet.search(/(?<!\\)\(/);
@@ -309,8 +313,7 @@ export function insertionOf(item: CompletionItem, caret: EditorPosition, lineBef
309
313
  return { range, text: raw, stops: [] };
310
314
  }
311
315
  const followed = range.end.line === caret.line && lineAfter.slice(Math.max(0, range.end.character - caret.character)).startsWith('(');
312
- const callable = item.kind === METHOD_KIND || item.kind === FUNCTION_KIND || item.kind === CONSTRUCTOR_KIND;
313
- const parsed = parseSnippet(followed && callable ? withoutCall(raw) : raw);
316
+ const parsed = parseSnippet(followed && isCallableKind(item.kind) ? withoutCall(raw) : raw);
314
317
  return { range, text: parsed.text, stops: tabOrder(parsed) };
315
318
  }
316
319
 
@@ -352,14 +355,11 @@ export function documentationText(documentation: string | MarkupContent | undefi
352
355
  if (documentation === undefined) {
353
356
  return '';
354
357
  }
355
- return typeof documentation === 'string'
356
- ? documentation
357
- : documentation.kind === 'plaintext'
358
- ? documentation.value.replace(/([\\`*_{}[\]()#+\-.!|<>~])/g, '\\$1')
359
- : documentation.value;
358
+ return typeof documentation === 'string' ? documentation : documentation.kind === 'plaintext' ? escapeMarkdown(documentation.value) : documentation.value;
360
359
  }
361
360
 
362
- const SOURCE_LINE = /^(?:use\s|Auto import from\b)/;
361
+ /* A detail line that only says where an item comes from: a PHP `use` or an auto import. */
362
+ export const SOURCE_LINE = /^(?:use\s|Auto import from\b)/;
363
363
 
364
364
  /*
365
365
  * What is said about a suggestion beside the list, in the shape a hover has: the signature it names and the
@@ -379,8 +379,6 @@ export function completionDocsOf(item: CompletionItem, highlightLanguage: string
379
379
  return source === '' && isEmptyHover(text) ? null : { source, text };
380
380
  }
381
381
 
382
- export type { InsertReplaceEdit, TextEdit };
383
-
384
382
  const KIND_LETTERS: Record<number, string> = {
385
383
  1: 't',
386
384
  2: 'm',
package/src/completion.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  import { PARAMETER_HINTS_COMMAND, isCallItem, planCall, withParentheses, type CallPlan } from './completion-call.ts';
21
21
  import { RecentChoices, recentChoicesOf } from './recent-choices.ts';
22
22
  import { shikiLanguageOf } from './language-ids.ts';
23
+ import { textBeforeOf } from './line-text.ts';
24
+ import { isEmptyRange } from './proposal-model.ts';
23
25
  import { isShortcut } from './shortcut-keys.ts';
24
26
  import type { CompletionRow, CompletionView } from './popups.ts';
25
27
  import { realTimers, type Timers } from './timers.ts';
@@ -33,10 +35,6 @@ const PAGE = 8;
33
35
  const LINES_BACK = 60;
34
36
  const TOAST_ID = 'language-completion';
35
37
 
36
- function isEmpty(range: EditorRange): boolean {
37
- return comparePositions(range.start, range.end) === 0;
38
- }
39
-
40
38
  /*
41
39
  * The suggestions that open while a word is typed. A single typed identifier character or a trigger
42
40
  * character of the server asks for the list; every character after that only filters the list that
@@ -120,7 +118,7 @@ export class CompletionFeature {
120
118
  item = await this.resolveNow(entry.item);
121
119
  }
122
120
  const caret = editor.getCaret();
123
- const lineBefore = editor.textInRange({ start: { line: caret.line, character: 0 }, end: caret });
121
+ const lineBefore = textBeforeOf(editor, caret);
124
122
  const lineAfter = editor.textInRange({ start: caret, end: { line: caret.line, character: Number.MAX_SAFE_INTEGER } });
125
123
  // A character that commits is typed after the name, so a call is not added before it.
126
124
  const planned = insertionOf(item, caret, lineBefore, replace, commit === undefined ? lineAfter : '(');
@@ -310,13 +308,10 @@ export class CompletionFeature {
310
308
  const triggers = this.triggerCharacters();
311
309
  const last = Array.from(text).at(-1) ?? '';
312
310
  const typedIdentifier = text !== '' && Array.from(text).every(isIdentifierCharacter);
313
- if (text.length > 0 && triggers.includes(last) && text.length === 1) {
311
+ if (text.length === 1 && triggers.includes(last)) {
314
312
  this.close();
315
313
  this.beginRequest({ triggerKind: 2, triggerCharacter: last }, 0);
316
- } else if (
317
- isEmpty(range) &&
318
- opensCompletion(this.language.editor.textInRange({ start: { line: range.start.line, character: 0 }, end: range.start }), text)
319
- ) {
314
+ } else if (isEmptyRange(range) && opensCompletion(textBeforeOf(this.language.editor, range.start), text)) {
320
315
  this.close();
321
316
  this.beginRequest({ triggerKind: 1 }, START_DELAY_MS);
322
317
  } else if (this.isOpen) {
@@ -326,7 +321,7 @@ export class CompletionFeature {
326
321
  } else {
327
322
  this.close();
328
323
  }
329
- } else if (text.length === 1 && typedIdentifier && isEmpty(range)) {
324
+ } else if (text.length === 1 && typedIdentifier && isEmptyRange(range)) {
330
325
  this.beginRequest({ triggerKind: 1 }, START_DELAY_MS);
331
326
  }
332
327
  }
@@ -393,7 +388,7 @@ export class CompletionFeature {
393
388
  this.server = project.host.serverNames?.(uri)[0] ?? '';
394
389
  if (!refresh) {
395
390
  const caret = editor.getCaret();
396
- const lineBefore = editor.textInRange({ start: { line: caret.line, character: 0 }, end: caret });
391
+ const lineBefore = textBeforeOf(editor, caret);
397
392
  const prefix = context.triggerKind === 2 ? '' : identifierPrefix(lineBefore);
398
393
  this.start = { line: caret.line, character: caret.character - prefix.length };
399
394
  this.active = 0;
@@ -419,7 +414,7 @@ export class CompletionFeature {
419
414
  this.close();
420
415
  return;
421
416
  }
422
- const lineBefore = editor.textInRange({ start: { line: caret.line, character: 0 }, end: caret });
417
+ const lineBefore = textBeforeOf(editor, caret);
423
418
  // Most items of a list replace the same range, so the text under it is read once and not once per item.
424
419
  const typed = new Map<string, string>();
425
420
  const textBetween = (range: EditorRange): string => {
@@ -3,9 +3,8 @@ import type { EditorLanguage } from './editor-language.ts';
3
3
  export type ClipboardAction = 'cut' | 'copy' | 'paste';
4
4
 
5
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.
6
+ * The editor only says where its context menu was asked for; `EditorContextMenu` draws it from what the
7
+ * servers support at the caret. The refactors are asked for while it opens, so it never waits for them.
9
8
  */
10
9
  export class ContextMenuFeature {
11
10
  private readonly language: EditorLanguage;
@@ -14,7 +13,7 @@ export class ContextMenuFeature {
14
13
  constructor(language: EditorLanguage) {
15
14
  this.language = language;
16
15
  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.
16
+ // A right click outside the selection moves the caret first, so the menu is about what was clicked.
18
17
  if (!menu.inSelection) {
19
18
  language.editor.setCaret(menu.position);
20
19
  }
@@ -33,7 +32,7 @@ export class ContextMenuFeature {
33
32
  }
34
33
  }
35
34
 
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. */
35
+ /* The browser's own commands on the editor's input, which keep the clipboard and the undo history one thing. */
37
36
  clipboard(action: ClipboardAction): void {
38
37
  const { editor } = this.language;
39
38
  editor.focus();
@@ -2,6 +2,7 @@ import type { EditorPosition } from '@adecore/editor';
2
2
  import { isIdentifierCharacter } from './completion-model.ts';
3
3
  import type { EditorLanguage } from './editor-language.ts';
4
4
  import { locationsOf } from './hover-content.ts';
5
+ import { lineTextOf } from './line-text.ts';
5
6
  import { wordRangeAt } from './rename-model.ts';
6
7
 
7
8
  /*
@@ -78,7 +79,7 @@ export class DefinitionLinkFeature {
78
79
  return;
79
80
  }
80
81
  const { editor, project, uri } = this.language;
81
- const line = editor.textInRange({ start: { line: pointer.line, character: 0 }, end: { line: pointer.line, character: Number.MAX_SAFE_INTEGER } });
82
+ const line = lineTextOf(editor, pointer.line);
82
83
  const word = isIdentifierCharacter(line[pointer.character] ?? '') ? wordRangeAt(line, pointer) : null;
83
84
  if (word === null) {
84
85
  this.clear();
@@ -1,5 +1,6 @@
1
1
  import type { EditorContentChange, EditorMarker, EditorMarkerSeverity, EditorPosition, EditorRange } from '@adecore/editor';
2
2
  import type { Diagnostic } from '@adecore/lsp';
3
+ import type { ProblemCounts } from './diagnostics.ts';
3
4
 
4
5
  /* A diagnostic and the language server process that reported it. */
5
6
  export interface Problem {
@@ -8,6 +9,7 @@ export interface Problem {
8
9
  }
9
10
 
10
11
  const SEVERITIES: Record<number, EditorMarkerSeverity> = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' };
12
+ const HINT = 4;
11
13
  const UNNECESSARY = 1;
12
14
  const DEPRECATED = 2;
13
15
  /* The scroll track's tip is a line or two, so a long message is cut where it stops being a tip. */
@@ -28,6 +30,12 @@ export function markerOf(diagnostic: Diagnostic): EditorMarker {
28
30
  };
29
31
  }
30
32
 
33
+ /* A diagnostic that names no severity counts as an error. */
34
+ export function problemCountsOf(diagnostics: readonly Diagnostic[]): ProblemCounts {
35
+ const count = (severity: number): number => diagnostics.filter((diagnostic) => (diagnostic.severity ?? 1) === severity).length;
36
+ return { error: count(1), warning: count(2), info: count(3) };
37
+ }
38
+
31
39
  export function comparePositions(left: EditorPosition, right: EditorPosition): number {
32
40
  return left.line === right.line ? left.character - right.character : left.line - right.line;
33
41
  }
@@ -74,14 +82,14 @@ export function rangeHolds(range: EditorRange, position: EditorPosition): boolea
74
82
  export function problemsAt(problems: readonly Problem[], position: EditorPosition): Problem[] {
75
83
  const rank = (problem: Problem): number => problem.diagnostic.severity ?? 1;
76
84
  return problems
77
- .filter((problem) => (problem.diagnostic.severity ?? 1) < 4 && rangeHolds(problem.diagnostic.range, position))
85
+ .filter((problem) => (problem.diagnostic.severity ?? 1) < HINT && rangeHolds(problem.diagnostic.range, position))
78
86
  .sort((left, right) => rank(left) - rank(right));
79
87
  }
80
88
 
81
89
  /* The start of the first problem after the position, or the last one before it when `direction` is back; it wraps round the document. */
82
90
  export function neighborProblem(problems: readonly Problem[], from: EditorPosition, direction: 1 | -1): Problem | null {
83
91
  const candidates = problems
84
- .filter((problem) => (problem.diagnostic.severity ?? 1) < 4)
92
+ .filter((problem) => (problem.diagnostic.severity ?? 1) < HINT)
85
93
  .sort((left, right) => comparePositions(left.diagnostic.range.start, right.diagnostic.range.start));
86
94
  if (candidates.length === 0) {
87
95
  return null;