@adecore/editor-react 0.17.0-beta.1 → 0.17.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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +35 -30
  3. package/dist/EditorView.d.ts +3 -2
  4. package/dist/EditorView.js +2 -2
  5. package/dist/HoverCard.d.ts +6 -0
  6. package/dist/HoverCard.js +20 -5
  7. package/dist/LanguagePopups.d.ts +3 -1
  8. package/dist/LanguagePopups.js +3 -3
  9. package/dist/code-actions-model.js +1 -1
  10. package/dist/code-actions.d.ts +1 -0
  11. package/dist/code-actions.js +19 -22
  12. package/dist/code-authors.d.ts +2 -1
  13. package/dist/code-authors.js +2 -3
  14. package/dist/code-vision-model.d.ts +2 -1
  15. package/dist/code-vision-model.js +3 -4
  16. package/dist/code-vision.js +12 -10
  17. package/dist/completion.js +1 -2
  18. package/dist/editor-language.d.ts +1 -0
  19. package/dist/editor-language.js +22 -0
  20. package/dist/folding.d.ts +2 -4
  21. package/dist/folding.js +11 -7
  22. package/dist/history.js +1 -2
  23. package/dist/host-types.d.ts +2 -0
  24. package/dist/hover.js +1 -2
  25. package/dist/inlay-hints.js +3 -5
  26. package/dist/language-ids.js +4 -2
  27. package/dist/locales/en.json +1 -0
  28. package/dist/locales/nl.json +1 -0
  29. package/dist/navigation.d.ts +1 -0
  30. package/dist/navigation.js +8 -9
  31. package/dist/peek.d.ts +1 -0
  32. package/dist/peek.js +6 -7
  33. package/dist/project-language.d.ts +2 -0
  34. package/dist/project-language.js +3 -0
  35. package/dist/rename.d.ts +1 -0
  36. package/dist/rename.js +10 -11
  37. package/dist/semantic-tokens.js +3 -5
  38. package/dist/symbol-picker.js +2 -3
  39. package/dist/symbols.js +3 -5
  40. package/dist/workspace-edit.d.ts +1 -0
  41. package/dist/workspace-edit.js +46 -7
  42. package/package.json +5 -5
  43. package/src/EditorView.tsx +5 -3
  44. package/src/HoverCard.tsx +44 -25
  45. package/src/LanguagePopups.tsx +8 -4
  46. package/src/code-actions-model.ts +1 -1
  47. package/src/code-actions.ts +20 -23
  48. package/src/code-authors.ts +3 -3
  49. package/src/code-vision-model.ts +4 -4
  50. package/src/code-vision.ts +12 -10
  51. package/src/completion.ts +1 -2
  52. package/src/editor-language.ts +26 -0
  53. package/src/folding.ts +14 -10
  54. package/src/history.ts +1 -2
  55. package/src/host-types.ts +3 -0
  56. package/src/hover.ts +1 -2
  57. package/src/inlay-hints.ts +3 -5
  58. package/src/language-ids.ts +4 -2
  59. package/src/locales/en.json +1 -0
  60. package/src/locales/nl.json +1 -0
  61. package/src/navigation.ts +9 -10
  62. package/src/peek.ts +7 -8
  63. package/src/project-language.ts +3 -0
  64. package/src/rename.ts +11 -12
  65. package/src/semantic-tokens.ts +3 -5
  66. package/src/symbol-picker.ts +2 -3
  67. package/src/symbols.ts +3 -5
  68. package/src/workspace-edit.ts +70 -10
package/src/HoverCard.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useTranslation } from 'react-i18next';
2
2
  import { CircleX, Info, TriangleAlert } from 'lucide-react';
3
- import { fileUriToPath } from '@adecore/lsp';
3
+ import { fileUriToPath, type Location } from '@adecore/lsp';
4
4
  import { Button, Icon, Tooltip } from '@adecore/ui';
5
5
  import { formatNumber } from '@adecore/ui/format';
6
6
  import type { EditorPosition } from '@adecore/editor';
@@ -13,6 +13,28 @@ import { SymbolSections } from './HoverSections.tsx';
13
13
  const SEVERITY_ICONS = { error: CircleX, warning: TriangleAlert, info: Info, hint: Info } as const;
14
14
  const SEVERITY_COLORS = { error: 'text-status-error', warning: 'text-status-needs-you', info: 'text-status-running', hint: 'text-text-muted' } as const;
15
15
 
16
+ /*
17
+ * Where a definition is: only its line in the file of the card, else the file and the line, with a long name cut
18
+ * short before the line. A definition outside the project folder, such as the stubs of a standard library, has none.
19
+ */
20
+ export function DefinitionPlace({ definition, uri, folder }: { definition: Location; uri: string; folder: string }) {
21
+ const { t } = useTranslation('editor');
22
+ const path = fileUriToPath(definition.uri);
23
+ const line = formatNumber(definition.range.start.line + 1);
24
+ if (path === null || (folder !== '' && !path.startsWith(`${folder}/`))) {
25
+ return null;
26
+ }
27
+ if (definition.uri === uri) {
28
+ return <span className="text-text-faint">{t('language.hover.line', { line })}</span>;
29
+ }
30
+ return (
31
+ <span className="flex min-w-0 font-mono text-text-faint">
32
+ <span className="truncate">{basenameOf(path)}</span>
33
+ <span className="shrink-0">:{line}</span>
34
+ </span>
35
+ );
36
+ }
37
+
16
38
  function ProblemSection({ problem, language }: { problem: Problem; language: EditorLanguage }) {
17
39
  const { t } = useTranslation('editor');
18
40
  const { diagnostic } = problem;
@@ -75,7 +97,6 @@ export function HoverCard({
75
97
  position: EditorPosition;
76
98
  }) {
77
99
  const { t } = useTranslation('editor');
78
- const place = info?.definition === null || info === null ? null : fileUriToPath(info.definition.uri);
79
100
  return (
80
101
  <div className="flex w-max min-w-[280px] max-w-[min(520px,calc(100vw-16px))] flex-col divide-y divide-border">
81
102
  {info !== null && (
@@ -87,29 +108,27 @@ export function HoverCard({
87
108
  void language.navigation.goToName(name, anchor, name === (declared ?? info.word) ? info.definition : null);
88
109
  }}
89
110
  />
90
- <div className="flex items-center gap-3 px-3 py-1.5 text-xs">
91
- {info.definition !== null && (
92
- <button type="button" className="text-accent hover:underline" onClick={() => language.goTo(info.definition!)}>
93
- {t('language.hover.definition')}
94
- </button>
95
- )}
96
- {info.references !== null && info.references > 0 && (
97
- <button
98
- type="button"
99
- className="text-accent hover:underline"
100
- onClick={() => {
101
- language.hover.hide();
102
- void language.peek.open(position);
103
- }}
104
- >
105
- {t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) })}
106
- </button>
107
- )}
108
- {place !== null && info.definition !== null && (
109
- <span className="ml-auto font-mono text-text-faint">
110
- {basenameOf(place)}:{formatNumber(info.definition.range.start.line + 1)}
111
- </span>
112
- )}
111
+ <div className="flex flex-col gap-0.5 px-3 py-1.5 text-xs">
112
+ <div className="flex items-center gap-3 whitespace-nowrap">
113
+ {info.definition !== null && (
114
+ <button type="button" className="text-accent hover:underline" onClick={() => language.goTo(info.definition!)}>
115
+ {t('language.hover.definition')}
116
+ </button>
117
+ )}
118
+ {info.references !== null && info.references > 0 && (
119
+ <button
120
+ type="button"
121
+ className="text-accent hover:underline"
122
+ onClick={() => {
123
+ language.hover.hide();
124
+ void language.peek.open(position);
125
+ }}
126
+ >
127
+ {t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) })}
128
+ </button>
129
+ )}
130
+ </div>
131
+ {info.definition !== null && <DefinitionPlace definition={info.definition} uri={language.uri} folder={language.project.folder} />}
113
132
  </div>
114
133
  </>
115
134
  )}
@@ -1,4 +1,4 @@
1
- import { useEffect, useReducer } from 'react';
1
+ import { useEffect, useReducer, type ReactNode } from 'react';
2
2
  import { useStore } from 'zustand';
3
3
  import type { EditorLanguage } from './editor-language.ts';
4
4
  import { AnchoredPopup } from './AnchoredPopup.tsx';
@@ -15,9 +15,9 @@ import { SignatureCard } from './SignatureCard.tsx';
15
15
  /*
16
16
  * Every card a language feature opens over the editor. Each feature keeps its own state and these
17
17
  * only draw it, placed by the screen position of the character it belongs to, which is read again
18
- * whenever the editor scrolls or resizes.
18
+ * whenever the editor scrolls or resizes. `contextMenuItems` adds rows of the app to the context menu.
19
19
  */
20
- export function LanguagePopups({ language }: { language: EditorLanguage }) {
20
+ export function LanguagePopups({ language, contextMenuItems }: { language: EditorLanguage; contextMenuItems?: (language: EditorLanguage) => ReactNode }) {
21
21
  const [, redraw] = useReducer((count: number) => count + 1, 0);
22
22
  const hover = useStore(language.popups, (state) => state.hover);
23
23
  const completion = useStore(language.popups, (state) => state.completion);
@@ -51,7 +51,11 @@ export function LanguagePopups({ language }: { language: EditorLanguage }) {
51
51
  )}
52
52
  {completion !== null && completionRect !== null && <CompletionPopup language={language} view={completion} rect={completionRect} />}
53
53
  {pick !== null && pickRect !== null && <PickPopup language={language} view={pick} rect={pickRect} />}
54
- {menu !== null && <EditorContextMenu language={language} view={menu} />}
54
+ {menu !== null && (
55
+ <EditorContextMenu language={language} view={menu}>
56
+ {contextMenuItems?.(language)}
57
+ </EditorContextMenu>
58
+ )}
55
59
  {symbols !== null && <SymbolPicker language={language} view={symbols} />}
56
60
  {peek !== null && <PeekPanel language={language} view={peek} />}
57
61
  {authors !== null && <CodeAuthorsCard language={language} view={authors} />}
@@ -107,7 +107,7 @@ function linesOf(text: string): string[] {
107
107
  return text.split(/\r\n|\r|\n/);
108
108
  }
109
109
 
110
- /* Null when the edit changes nothing, or creates or deletes a file, which a preview of lines cannot show. */
110
+ /* Null when the edit changes nothing, or deletes a file, which a preview of lines cannot show. A file it creates counts as another file. */
111
111
  export function previewOf(text: string, edit: WorkspaceEdit, uri: string): EditPreview | null {
112
112
  const entries = entriesOf(edit);
113
113
  if (entries === null) {
@@ -1,4 +1,3 @@
1
- import i18next from 'i18next';
2
1
  import { ErrorCodes, LspError, StaleResultError, type CodeAction, type Command, type Diagnostic, type Position } from '@adecore/lsp';
3
2
  import type { EditorPosition, EditorRange } from '@adecore/editor';
4
3
  import {
@@ -49,10 +48,6 @@ interface AskOptions {
49
48
  readonly groups?: readonly ActionGroup[];
50
49
  }
51
50
 
52
- function say(key: string, options?: Record<string, unknown>): string {
53
- return i18next.t(`editor:language.actions.${key}`, options);
54
- }
55
-
56
51
  /*
57
52
  * What the language servers offer to do at the caret or the selection: a lightbulb in the gutter on the line
58
53
  * that has something, a list under the caret (Mod+.) grouped by kind with the change an action would make
@@ -72,12 +67,10 @@ export class CodeActionsFeature {
72
67
 
73
68
  constructor(language: EditorLanguage, timers: Timers = realTimers) {
74
69
  this.language = language;
75
- const { editor, project, uri } = language;
70
+ const { editor } = language;
76
71
  this.refresher = new Refresher((signal) => this.hint(signal), PAUSE_MS, timers);
77
- const providers = project.service.onProvidersChanged((changed) => {
78
- if (changed === uri) {
79
- this.refresher.later();
80
- }
72
+ const providers = language.onProvidersChanged(() => {
73
+ this.refresher.later();
81
74
  });
82
75
  const offs = [
83
76
  editor.onCaret(() => {
@@ -98,7 +91,7 @@ export class CodeActionsFeature {
98
91
  return false;
99
92
  }),
100
93
  language.diagnostics.onChange(() => this.refresher.later()),
101
- () => providers.dispose()
94
+ providers
102
95
  ];
103
96
  language.onDispose(() => {
104
97
  for (const off of offs) {
@@ -137,7 +130,7 @@ export class CodeActionsFeature {
137
130
  const entries = await this.request(everything, ['source.organizeImports'], INVOKED);
138
131
  const entry = entries?.find((candidate) => candidate.action.kind?.startsWith('source.organizeImports') === true);
139
132
  if (entry === undefined) {
140
- this.tell('success', say(entries === null ? 'unavailable' : 'organized'));
133
+ this.tell('success', this.say(entries === null ? 'unavailable' : 'organized'));
141
134
  return;
142
135
  }
143
136
  await this.apply(entry);
@@ -146,13 +139,13 @@ export class CodeActionsFeature {
146
139
  async formatDocument(): Promise<void> {
147
140
  const { editor, project, uri } = this.language;
148
141
  if (!project.service.supports('textDocument/formatting', uri)) {
149
- this.tell('error', say('noFormatter'));
142
+ this.tell('error', this.say('noFormatter'));
150
143
  return;
151
144
  }
152
145
  try {
153
146
  const edits = await project.service.formatting(uri, { ...editor.getIndentation() });
154
147
  if (edits !== null && edits.length > 0 && !editor.applyEdits(edits.map((edit) => ({ range: edit.range, text: edit.newText })))) {
155
- this.tell('error', say('readOnly'));
148
+ this.tell('error', this.say('readOnly'));
156
149
  }
157
150
  } catch (error) {
158
151
  this.fail(error);
@@ -175,7 +168,7 @@ export class CodeActionsFeature {
175
168
 
176
169
  private async ask(options: AskOptions): Promise<void> {
177
170
  if (!this.supported) {
178
- this.tell('error', say('unavailable'));
171
+ this.tell('error', this.say('unavailable'));
179
172
  return;
180
173
  }
181
174
  // The press on a card or the gutter may have taken the focus, and the list answers to the editor's keys.
@@ -193,13 +186,13 @@ export class CodeActionsFeature {
193
186
  return;
194
187
  }
195
188
  if (entries.length === 0) {
196
- this.tell('success', say('none'));
189
+ this.tell('success', this.say('none'));
197
190
  return;
198
191
  }
199
192
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
200
193
  const groups: PickGroup[] = (options.groups ?? ACTION_GROUPS).flatMap((group): PickGroup[] => {
201
194
  const rows = entries.filter((entry) => entry.group === group).map((entry) => ({ id: entry.id, label: entry.action.title, detail: '' }));
202
- return rows.length === 0 ? [] : [{ title: say(`groups.${group}`), rows }];
195
+ return rows.length === 0 ? [] : [{ title: this.say(`groups.${group}`), rows }];
203
196
  });
204
197
  this.language.completion.close();
205
198
  this.language.pick.open({
@@ -278,7 +271,7 @@ export class CodeActionsFeature {
278
271
  if (signal.aborted || this.asking) {
279
272
  return;
280
273
  }
281
- editor.setGutterAction(entries?.some(isHint) === true ? { line: editor.getCaret().line, label: say('show') } : null);
274
+ editor.setGutterAction(entries?.some(isHint) === true ? { line: editor.getCaret().line, label: this.say('show') } : null);
282
275
  }
283
276
 
284
277
  private async resolve(entry: ActionEntry, signal?: AbortSignal): Promise<CodeAction> {
@@ -325,9 +318,9 @@ export class CodeActionsFeature {
325
318
  return null;
326
319
  }
327
320
  const notes = [
328
- ...(found.hiddenLines > 0 ? [say('moreLines', { count: found.hiddenLines })] : []),
329
- ...(found.otherFiles > 0 ? [say('otherFiles', { count: found.otherFiles })] : []),
330
- ...(found.moves > 0 ? [say('moves', { count: found.moves })] : [])
321
+ ...(found.hiddenLines > 0 ? [this.say('moreLines', { count: found.hiddenLines })] : []),
322
+ ...(found.otherFiles > 0 ? [this.say('otherFiles', { count: found.otherFiles })] : []),
323
+ ...(found.moves > 0 ? [this.say('moves', { count: found.moves })] : [])
331
324
  ];
332
325
  return { removed: found.removed, added: found.added, note: notes.length === 0 ? null : notes.join(' · ') };
333
326
  }
@@ -353,7 +346,7 @@ export class CodeActionsFeature {
353
346
  if (action.edit !== undefined) {
354
347
  const result = await project.applyWorkspaceEdit(action.edit);
355
348
  if (!result.applied) {
356
- this.tell('error', say('failed', { message: result.failureReason ?? say('refused') }));
349
+ this.tell('error', this.say('failed', { message: result.failureReason ?? this.say('refused') }));
357
350
  return;
358
351
  }
359
352
  }
@@ -381,7 +374,11 @@ export class CodeActionsFeature {
381
374
 
382
375
  private fail(error: unknown): void {
383
376
  if (!(error instanceof StaleResultError)) {
384
- this.tell('error', say('failed', { message: error instanceof Error ? error.message : String(error) }));
377
+ this.tell('error', this.say('failed', { message: error instanceof Error ? error.message : String(error) }));
385
378
  }
386
379
  }
380
+
381
+ private say(key: string, options?: Record<string, unknown>): string {
382
+ return this.language.project.i18n.t(`editor:language.actions.${key}`, options);
383
+ }
387
384
  }
@@ -1,4 +1,4 @@
1
- import i18next from 'i18next';
1
+ import type { i18n } from 'i18next';
2
2
  import { diffLines, splitLines } from '@adecore/merge';
3
3
  import type { GitBlameCommit, GitBlameResult } from './host-types.ts';
4
4
  import { formatNumber } from '@adecore/ui/format';
@@ -94,10 +94,10 @@ export function authorshipOf(commits: readonly GitBlameCommit[], mapped: Int32Ar
94
94
  }
95
95
 
96
96
  /* The row's words: the author with most lines, how many others wrote some, and a star when the range is edited since the commit. */
97
- export function authorsText(authorship: CodeAuthorship): string {
97
+ export function authorsText(authorship: CodeAuthorship, i18n: i18n): string {
98
98
  const main = authorship.authors[0];
99
99
  if (main === undefined) {
100
- return i18next.t('editor:language.codeVision.newCode');
100
+ return i18n.t('editor:language.codeVision.newCode');
101
101
  }
102
102
  const others = authorship.authors.length - 1;
103
103
  return `${main.name}${others > 0 ? ` +${formatNumber(others)}` : ''}${authorship.modified ? ' *' : ''}`;
@@ -1,6 +1,6 @@
1
1
  import type { DocumentSymbol, DocumentSymbolResult, Position, SymbolInformation } from '@adecore/lsp';
2
2
  import { formatNumber } from '@adecore/ui/format';
3
- import i18next from 'i18next';
3
+ import type { i18n } from 'i18next';
4
4
 
5
5
  /* More declarations than this in one file and the rows are left out, which is the file that is too big for them to be quiet. */
6
6
  export const MAX_DECLARATIONS = 3000;
@@ -223,8 +223,8 @@ export function declarationsOf(result: DocumentSymbolResult, source: Declaration
223
223
  }
224
224
 
225
225
  /* "No usages", "1 usage" and "N usages". */
226
- export function usagesText(count: number): string {
226
+ export function usagesText(count: number, i18n: i18n): string {
227
227
  return count === 0
228
- ? i18next.t('editor:language.codeVision.usagesNone')
229
- : i18next.t('editor:language.codeVision.usages', { count, formatted: formatNumber(count) });
228
+ ? i18n.t('editor:language.codeVision.usagesNone')
229
+ : i18n.t('editor:language.codeVision.usages', { count, formatted: formatNumber(count) });
230
230
  }
@@ -62,7 +62,7 @@ export class CodeVisionFeature {
62
62
  constructor(language: EditorLanguage, onSymbols: (listener: (result: DocumentSymbolResult) => void) => () => void, timers: Timers = realTimers) {
63
63
  this.language = language;
64
64
  this.timers = timers;
65
- const { editor, project, uri } = language;
65
+ const { editor } = language;
66
66
  const offs = [
67
67
  onSymbols((result) => this.read(result)),
68
68
  editor.onTextChange(() => {
@@ -74,19 +74,17 @@ export class CodeVisionFeature {
74
74
  this.scrollTimer = this.timers.set(() => this.plan(), SCROLL_PAUSE_MS);
75
75
  })
76
76
  ];
77
- const providers = project.service.onProvidersChanged((changed) => {
78
- if (changed === uri) {
79
- this.failed.clear();
80
- this.publish();
81
- this.plan();
82
- }
77
+ const providers = language.onProvidersChanged(() => {
78
+ this.failed.clear();
79
+ this.publish();
80
+ this.plan();
83
81
  });
84
82
  language.onDispose(() => {
85
83
  this.disposed = true;
86
84
  for (const off of offs) {
87
85
  off();
88
86
  }
89
- providers.dispose();
87
+ providers();
90
88
  this.timers.clear(this.scrollTimer);
91
89
  for (const controller of this.running.values()) {
92
90
  controller.abort();
@@ -209,7 +207,11 @@ export class CodeVisionFeature {
209
207
  const entries: EditorCodeVisionEntry[] = [];
210
208
  const known = this.counts.get(declaration.id);
211
209
  if (counted && declaration.usages && known !== undefined) {
212
- entries.push({ id: 'usages', text: usagesText(known.count), activate: () => void this.language.peek.open(declaration.position) });
210
+ entries.push({
211
+ id: 'usages',
212
+ text: usagesText(known.count, this.language.project.i18n),
213
+ activate: () => void this.language.peek.open(declaration.position)
214
+ });
213
215
  }
214
216
  if (mapped !== null && declaration.authors) {
215
217
  let authorship = this.authorships.get(declaration.id);
@@ -220,7 +222,7 @@ export class CodeVisionFeature {
220
222
  const shown = authorship;
221
223
  entries.push({
222
224
  id: 'authors',
223
- text: authorsText(shown),
225
+ text: authorsText(shown, this.language.project.i18n),
224
226
  ...(shown.authors.length > 0 ? { icon: shown.authors.length > 1 ? ('users' as const) : ('user' as const) } : {}),
225
227
  activate: (anchor) => this.showAuthors(declaration, shown, anchor)
226
228
  });
package/src/completion.ts CHANGED
@@ -1,4 +1,3 @@
1
- import i18next from 'i18next';
2
1
  import { StaleResultError, type CompletionContext, type CompletionItem } from '@adecore/lsp';
3
2
  import type { EditorContentChange, EditorPosition, EditorRange, EditorTextChange } from '@adecore/editor';
4
3
  import { comparePositions, shiftPosition } from './diagnostics-model.ts';
@@ -399,7 +398,7 @@ export class CompletionFeature {
399
398
  const asked = this.explicit && !refresh;
400
399
  this.refilter();
401
400
  if (asked && !this.isOpen && !this.incomplete) {
402
- this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title: i18next.t('editor:language.completion.none') });
401
+ this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title: this.language.project.i18n.t('editor:language.completion.none') });
403
402
  } else if (asked && this.ranked.length === 1 && !this.incomplete) {
404
403
  // One answer to a list that was asked for is the one wanted.
405
404
  void this.accept(false, 0);
@@ -145,6 +145,32 @@ export class EditorLanguage {
145
145
  this.pick.open({ anchor, title, groups: [{ title: null, rows }], accept: (id) => this.goTo(places[Number(id)]!) });
146
146
  }
147
147
 
148
+ /*
149
+ * Runs `listener` once the document is open and again whenever the service says what it can ask for
150
+ * this document changed. A service that is ready before the document opens never says so, so the
151
+ * opening counts as the first change.
152
+ */
153
+ onProvidersChanged(listener: () => void): () => void {
154
+ let listening = true;
155
+ void this.document.ready.then(
156
+ () => {
157
+ if (listening && !this.disposed) {
158
+ listener();
159
+ }
160
+ },
161
+ () => undefined
162
+ );
163
+ const providers = this.project.service.onProvidersChanged((uri) => {
164
+ if (uri === this.uri) {
165
+ listener();
166
+ }
167
+ });
168
+ return () => {
169
+ listening = false;
170
+ providers.dispose();
171
+ };
172
+ }
173
+
148
174
  /* Runs `dispose` when the editor lets go of its document. */
149
175
  onDispose(dispose: () => void): void {
150
176
  this.disposers.push(dispose);
package/src/folding.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EditorFoldHints, EditorFoldRange, EditorFoldSymbol } from '@adecore/editor';
1
+ import type { EditorFoldHints, EditorFoldRangeHint, EditorFoldSymbol } from '@adecore/editor';
2
2
  import type { DocumentSymbol, DocumentSymbolResult, FoldingRange, SymbolInformation } from '@adecore/lsp';
3
3
  import type { EditorLanguage } from './editor-language.ts';
4
4
  import { Refresher } from './refresher.ts';
@@ -51,11 +51,17 @@ export function foldSymbolsOf(result: DocumentSymbolResult): EditorFoldSymbol[]
51
51
  return found;
52
52
  }
53
53
 
54
- /* The ranges a server folds that span more than one line. */
55
- export function foldRangesOf(ranges: readonly FoldingRange[] | null): (EditorFoldRange & { kind?: string })[] {
54
+ /* The ranges a server folds that span more than one line, with where a range starts and ends within its lines when it says. */
55
+ export function foldRangesOf(ranges: readonly FoldingRange[] | null): EditorFoldRangeHint[] {
56
56
  return (ranges ?? [])
57
57
  .filter((range) => range.endLine > range.startLine)
58
- .map((range) => ({ startLine: range.startLine, endLine: range.endLine, ...(range.kind === undefined ? {} : { kind: range.kind }) }));
58
+ .map((range) => ({
59
+ startLine: range.startLine,
60
+ endLine: range.endLine,
61
+ ...(range.kind === undefined ? {} : { kind: range.kind }),
62
+ ...(range.startCharacter === undefined ? {} : { startCharacter: range.startCharacter }),
63
+ ...(range.endCharacter === undefined ? {} : { endCharacter: range.endCharacter })
64
+ }));
59
65
  }
60
66
 
61
67
  /*
@@ -65,7 +71,7 @@ export function foldRangesOf(ranges: readonly FoldingRange[] | null): (EditorFol
65
71
  */
66
72
  export class FoldingFeature {
67
73
  private symbols: EditorFoldSymbol[] = [];
68
- private ranges: (EditorFoldRange & { kind?: string })[] = [];
74
+ private ranges: EditorFoldRangeHint[] = [];
69
75
  private readonly refresher: Refresher;
70
76
  private readonly language: EditorLanguage;
71
77
 
@@ -91,15 +97,13 @@ export class FoldingFeature {
91
97
  this.publish();
92
98
  });
93
99
  const edits = editor.onTextChange(() => this.refresher.later());
94
- const providers = project.service.onProvidersChanged((changed) => {
95
- if (changed === uri) {
96
- this.refresher.now();
97
- }
100
+ const providers = language.onProvidersChanged(() => {
101
+ this.refresher.now();
98
102
  });
99
103
  language.onDispose(() => {
100
104
  symbols();
101
105
  edits();
102
- providers.dispose();
106
+ providers();
103
107
  this.refresher.dispose();
104
108
  editor.setFoldHints(null);
105
109
  });
package/src/history.ts CHANGED
@@ -1,4 +1,3 @@
1
- import i18next from 'i18next';
2
1
  import type { EditorLanguage } from './editor-language.ts';
3
2
  import { isShortcut } from './shortcut-keys.ts';
4
3
 
@@ -51,7 +50,7 @@ export class HistoryFeature {
51
50
  this.language.locations(
52
51
  places.map((place) => ({ uri: place.uri, range: { start: place.position, end: place.position } })),
53
52
  this.language.editor.getCaret(),
54
- i18next.t('editor:language.history.recent')
53
+ this.language.project.i18n.t('editor:language.history.recent')
55
54
  );
56
55
  }
57
56
  }
package/src/host-types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { i18n } from 'i18next';
1
2
  import type { Keymap } from '@adecore/editor/keymap';
2
3
  import type { ProjectFiles } from './workspace-edit.ts';
3
4
  import type { Place } from './navigation-history.ts';
@@ -37,6 +38,8 @@ export interface RenameSuggestionsRequest {
37
38
  }
38
39
 
39
40
  export interface LanguageHost {
41
+ /* The instance the app hands `UIProvider`, which the features read their words from too; the default instance without one. */
42
+ readonly i18n?: i18n;
40
43
  readonly folder?: string;
41
44
  readonly files?: ProjectFiles;
42
45
  readonly apple?: boolean;
package/src/hover.ts CHANGED
@@ -1,4 +1,3 @@
1
- import i18next from 'i18next';
2
1
  import type { EditorHover, EditorPosition, EditorRange } from '@adecore/editor';
3
2
  import type { EditorLanguage } from './editor-language.ts';
4
3
  import { rangeHolds, type Problem } from './diagnostics-model.ts';
@@ -212,7 +211,7 @@ export class HoverFeature {
212
211
  if (problems.length === 0 && info === null) {
213
212
  this.hide();
214
213
  if (pinned) {
215
- this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title: i18next.t('editor:language.hover.none') });
214
+ this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title: this.language.project.i18n.t('editor:language.hover.none') });
216
215
  }
217
216
  return;
218
217
  }
@@ -71,15 +71,13 @@ export class InlayHintsFeature {
71
71
  this.refresher.later();
72
72
  }
73
73
  });
74
- const providers = project.service.onProvidersChanged((changed) => {
75
- if (changed === uri) {
76
- this.refresher.now();
77
- }
74
+ const providers = language.onProvidersChanged(() => {
75
+ this.refresher.now();
78
76
  });
79
77
  language.onDispose(() => {
80
78
  edits();
81
79
  scrolls();
82
- providers.dispose();
80
+ providers();
83
81
  this.refresher.dispose();
84
82
  editor.setInlayHints([]);
85
83
  });
@@ -15,7 +15,8 @@ const LSP_IDS: Record<string, string> = {
15
15
  yaml: 'yaml',
16
16
  python: 'python',
17
17
  shellscript: 'shellscript',
18
- docker: 'dockerfile'
18
+ docker: 'dockerfile',
19
+ twig: 'twig'
19
20
  };
20
21
 
21
22
  /* The LSP language id of a file, or null when no language server here knows its language. */
@@ -45,7 +46,8 @@ const PATH_IDS: Record<string, string> = {
45
46
  css: 'css',
46
47
  scss: 'scss',
47
48
  html: 'html',
48
- md: 'markdown'
49
+ md: 'markdown',
50
+ twig: 'twig'
49
51
  };
50
52
 
51
53
  /* The highlighter id for the code of a file we only know by its path, such as a place another file refers to. */
@@ -6,6 +6,7 @@
6
6
  "definition": "Go to definition",
7
7
  "references_one": "{{formatted}} reference",
8
8
  "references_other": "{{formatted}} references",
9
+ "line": "Line {{line}}",
9
10
  "none": "No information for this position"
10
11
  },
11
12
  "edit": {
@@ -6,6 +6,7 @@
6
6
  "definition": "Ga naar definitie",
7
7
  "references_one": "{{formatted}} referentie",
8
8
  "references_other": "{{formatted}} referenties",
9
+ "line": "Regel {{line}}",
9
10
  "none": "Geen informatie voor deze plek"
10
11
  },
11
12
  "edit": {
package/src/navigation.ts CHANGED
@@ -1,4 +1,3 @@
1
- import i18next from 'i18next';
2
1
  import { StaleResultError, fileUriToPath, type Location, type LocationLink, type NavigationResult, type Range } from '@adecore/lsp';
3
2
  import type { EditorPosition } from '@adecore/editor';
4
3
  import { basenameOf } from './paths.ts';
@@ -24,10 +23,6 @@ const SHORTCUT_KINDS: readonly [NavigationKind, 'goToDefinition' | 'goToTypeDefi
24
23
 
25
24
  const TOAST_ID = 'language-navigation';
26
25
 
27
- function say(key: string, options?: Record<string, unknown>): string {
28
- return i18next.t(`editor:language.navigation.${key}`, options);
29
- }
30
-
31
26
  /* The places of a result, each once: the two servers of a Vue file may both know the same one. `target` is the whole declaration when the server gave it. */
32
27
  export function uniquePlaces(result: NavigationResult): { location: Location; target: Range | null }[] {
33
28
  const seen = new Set<string>();
@@ -96,7 +91,7 @@ export class NavigationFeature {
96
91
  async go(kind: NavigationKind, position: EditorPosition = this.language.editor.getCaret()): Promise<void> {
97
92
  const { project, uri, editor } = this.language;
98
93
  if (!this.supports(kind)) {
99
- this.tell(say('unavailable'));
94
+ this.tell(this.say('unavailable'));
100
95
  return;
101
96
  }
102
97
  let result: NavigationResult;
@@ -111,20 +106,20 @@ export class NavigationFeature {
111
106
  : service.implementation(uri, position));
112
107
  } catch (error) {
113
108
  if (!(error instanceof StaleResultError)) {
114
- this.tell(say('failed', { message: error instanceof Error ? error.message : String(error) }));
109
+ this.tell(this.say('failed', { message: error instanceof Error ? error.message : String(error) }));
115
110
  }
116
111
  return;
117
112
  }
118
113
  const places = uniqueLocations(result);
119
114
  if (places.length === 0) {
120
- this.tell(say('none'));
115
+ this.tell(this.say('none'));
121
116
  } else if (kind === 'definition' && places.length === 1 && places[0]!.uri === uri && rangeHolds(places[0]!.range, position)) {
122
117
  // The name is its own definition, so what a person wants to see is where it is used.
123
118
  void this.language.peek.open(position);
124
119
  } else if (places.length === 1) {
125
120
  this.language.goTo(places[0]!);
126
121
  } else {
127
- this.language.locations(places, position, say(`titles.${kind}`));
122
+ this.language.locations(places, position, this.say(`titles.${kind}`));
128
123
  }
129
124
  editor.focus();
130
125
  }
@@ -148,7 +143,7 @@ export class NavigationFeature {
148
143
  if (places.length === 1) {
149
144
  this.language.goTo(places[0]!);
150
145
  } else if (places.length > 1) {
151
- this.language.locations(places, anchor, say('titles.symbol'));
146
+ this.language.locations(places, anchor, this.say('titles.symbol'));
152
147
  }
153
148
  } catch {
154
149
  // A name that cannot be found is a link that does nothing, not an error.
@@ -158,6 +153,10 @@ export class NavigationFeature {
158
153
  private tell(title: string): void {
159
154
  this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title });
160
155
  }
156
+
157
+ private say(key: string, options?: Record<string, unknown>): string {
158
+ return this.language.project.i18n.t(`editor:language.navigation.${key}`, options);
159
+ }
161
160
  }
162
161
 
163
162
  /* How a place reads in a list: the file and the line, with the folder it is in. */