@adecore/editor-react 0.41.1 → 0.43.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 (124) hide show
  1. package/dist/CallHierarchyPanel.js +177 -181
  2. package/dist/EditorContextMenu.js +118 -105
  3. package/dist/EditorView.js +7 -1
  4. package/dist/LanguagePopups.js +7 -0
  5. package/dist/PickPopup.js +190 -70
  6. package/dist/WrapCard.d.ts +8 -0
  7. package/dist/WrapCard.js +172 -0
  8. package/dist/call-hierarchy-model.d.ts +4 -2
  9. package/dist/call-hierarchy-model.js +20 -0
  10. package/dist/call-hierarchy.d.ts +3 -0
  11. package/dist/call-hierarchy.js +54 -22
  12. package/dist/clipboard-history.d.ts +31 -0
  13. package/dist/clipboard-history.js +70 -0
  14. package/dist/code-actions.d.ts +3 -1
  15. package/dist/code-actions.js +50 -10
  16. package/dist/code-vision.js +2 -3
  17. package/dist/completion.js +10 -15
  18. package/dist/context-menu-rows.d.ts +1 -1
  19. package/dist/context-menu-rows.js +4 -0
  20. package/dist/definition-link.js +15 -5
  21. package/dist/document-colors.d.ts +24 -0
  22. package/dist/document-colors.js +194 -0
  23. package/dist/document-links.d.ts +22 -0
  24. package/dist/document-links.js +133 -0
  25. package/dist/document-query.d.ts +15 -0
  26. package/dist/document-query.js +54 -0
  27. package/dist/edit-locations.d.ts +20 -0
  28. package/dist/edit-locations.js +136 -0
  29. package/dist/editor-language.d.ts +19 -1
  30. package/dist/editor-language.js +77 -2
  31. package/dist/emmet.d.ts +13 -0
  32. package/dist/emmet.js +62 -0
  33. package/dist/folding.js +2 -3
  34. package/dist/format-on-type.js +3 -5
  35. package/dist/highlights.js +2 -3
  36. package/dist/history.d.ts +7 -0
  37. package/dist/history.js +79 -1
  38. package/dist/host-types.d.ts +6 -0
  39. package/dist/index.d.ts +11 -2
  40. package/dist/index.js +8 -0
  41. package/dist/inlay-hints.js +2 -2
  42. package/dist/inline-completion.d.ts +39 -0
  43. package/dist/inline-completion.js +221 -0
  44. package/dist/key-priority.d.ts +8 -5
  45. package/dist/key-priority.js +9 -6
  46. package/dist/linked-editing.d.ts +26 -0
  47. package/dist/linked-editing.js +215 -0
  48. package/dist/locales/en.json +31 -2
  49. package/dist/locales/nl.json +31 -2
  50. package/dist/markup-context.d.ts +37 -0
  51. package/dist/markup-context.js +291 -0
  52. package/dist/markup.d.ts +36 -0
  53. package/dist/markup.js +453 -0
  54. package/dist/navigation.js +4 -6
  55. package/dist/paste-history.d.ts +7 -0
  56. package/dist/paste-history.js +104 -0
  57. package/dist/peek.js +3 -4
  58. package/dist/pick.d.ts +20 -2
  59. package/dist/pick.js +141 -18
  60. package/dist/popups.d.ts +9 -0
  61. package/dist/popups.js +2 -1
  62. package/dist/project-language.d.ts +18 -1
  63. package/dist/project-language.js +129 -18
  64. package/dist/rename.js +8 -11
  65. package/dist/rendered-docs.js +1 -2
  66. package/dist/selection-ranges.js +2 -3
  67. package/dist/shortcut-keys.d.ts +27 -0
  68. package/dist/signature.js +3 -6
  69. package/dist/snippet-session.d.ts +2 -2
  70. package/dist/snippet-session.js +3 -3
  71. package/dist/symbol-picker.js +4 -6
  72. package/dist/symbols.js +22 -3
  73. package/dist/testing.d.ts +23 -1
  74. package/dist/testing.js +114 -0
  75. package/package.json +7 -6
  76. package/src/CallHierarchyPanel.tsx +26 -11
  77. package/src/EditorContextMenu.tsx +7 -0
  78. package/src/EditorView.tsx +9 -0
  79. package/src/LanguagePopups.tsx +4 -0
  80. package/src/PickPopup.tsx +40 -5
  81. package/src/WrapCard.tsx +64 -0
  82. package/src/call-hierarchy-model.ts +25 -3
  83. package/src/call-hierarchy.ts +62 -20
  84. package/src/clipboard-history.ts +102 -0
  85. package/src/code-actions.ts +48 -11
  86. package/src/code-vision.ts +3 -3
  87. package/src/completion.ts +15 -15
  88. package/src/context-menu-rows.ts +3 -2
  89. package/src/definition-link.ts +17 -5
  90. package/src/document-colors.ts +189 -0
  91. package/src/document-links.ts +129 -0
  92. package/src/document-query.ts +71 -0
  93. package/src/edit-locations.ts +162 -0
  94. package/src/editor-language.ts +80 -3
  95. package/src/emmet-module.d.ts +44 -0
  96. package/src/emmet.ts +73 -0
  97. package/src/folding.ts +3 -3
  98. package/src/format-on-type.ts +5 -5
  99. package/src/highlights.ts +3 -3
  100. package/src/history.ts +81 -1
  101. package/src/host-types.ts +10 -0
  102. package/src/index.ts +12 -3
  103. package/src/inlay-hints.ts +2 -2
  104. package/src/inline-completion.ts +221 -0
  105. package/src/key-priority.ts +9 -6
  106. package/src/linked-editing.ts +199 -0
  107. package/src/locales/en.json +31 -2
  108. package/src/locales/nl.json +31 -2
  109. package/src/markup-context.ts +262 -0
  110. package/src/markup.ts +367 -0
  111. package/src/navigation.ts +6 -6
  112. package/src/paste-history.ts +111 -0
  113. package/src/peek.ts +4 -4
  114. package/src/pick.ts +201 -19
  115. package/src/popups.ts +19 -1
  116. package/src/project-language.ts +136 -19
  117. package/src/rename.ts +11 -11
  118. package/src/rendered-docs.ts +2 -2
  119. package/src/selection-ranges.ts +3 -3
  120. package/src/signature.ts +6 -6
  121. package/src/snippet-session.ts +9 -4
  122. package/src/symbol-picker.ts +6 -6
  123. package/src/symbols.ts +30 -4
  124. package/src/testing.ts +126 -1
package/src/completion.ts CHANGED
@@ -236,7 +236,7 @@ export class CompletionFeature {
236
236
 
237
237
  recentChoicesOf(this.language.languageId).record(entry.item);
238
238
  let item = this.resolved.get(entry.item) ?? entry.item;
239
- const needsResolve = !this.resolved.has(entry.item) && project.service.providerOptions('completionItem/resolve', uri) !== undefined;
239
+ const needsResolve = !this.resolved.has(entry.item) && this.language.service.providerOptions('completionItem/resolve', uri) !== undefined;
240
240
  this.close();
241
241
 
242
242
  if (needsResolve) {
@@ -522,7 +522,7 @@ export class CompletionFeature {
522
522
 
523
523
  /* Parameter info for a call that was opened (or that the server asks for), then the item's own command. */
524
524
  private afterInsert(item: CompletionItem, commit: string | undefined, lineBefore: string): void {
525
- const { editor, project, uri, signature } = this.language;
525
+ const { editor, uri, signature } = this.language;
526
526
  const { start } = editor.getSelection();
527
527
  const insideCall = start.character > 0 && editor.textInRange({ start: { line: start.line, character: start.character - 1 }, end: start }) === '(';
528
528
 
@@ -539,7 +539,7 @@ export class CompletionFeature {
539
539
  if (command.command === PARAMETER_HINTS_COMMAND) {
540
540
  signature.trigger();
541
541
  } else {
542
- project.service.executeCommand(uri, command).catch(() => undefined);
542
+ this.language.service.executeCommand(uri, command).catch(() => undefined);
543
543
  }
544
544
  }
545
545
 
@@ -549,10 +549,10 @@ export class CompletionFeature {
549
549
  * suggestion is resolved in that one; an item that cannot be resolved is still inserted as it is.
550
550
  */
551
551
  private async resolveNow(item: CompletionItem, signal: AbortSignal): Promise<CompletionItem> {
552
- const { editor, project, uri } = this.language;
552
+ const { editor, uri } = this.language;
553
553
 
554
554
  try {
555
- return { ...item, ...(await project.service.resolveCompletion(uri, item, { signal })) };
555
+ return { ...item, ...(await this.language.service.resolveCompletion(uri, item, { signal })) };
556
556
  } catch (error) {
557
557
  if (!(error instanceof StaleResultError)) {
558
558
  return item;
@@ -560,11 +560,11 @@ export class CompletionFeature {
560
560
  }
561
561
 
562
562
  try {
563
- const fresh = itemsOf(await project.service.completion(uri, editor.getCaret(), { triggerKind: 1 }, { signal })).items.find(
563
+ const fresh = itemsOf(await this.language.service.completion(uri, editor.getCaret(), { triggerKind: 1 }, { signal })).items.find(
564
564
  (candidate) => candidate.label === item.label && candidate.kind === item.kind
565
565
  );
566
566
 
567
- return fresh === undefined ? item : { ...fresh, ...(await project.service.resolveCompletion(uri, fresh, { signal }).catch(() => fresh)) };
567
+ return fresh === undefined ? item : { ...fresh, ...(await this.language.service.resolveCompletion(uri, fresh, { signal }).catch(() => fresh)) };
568
568
  } catch {
569
569
  return item;
570
570
  }
@@ -703,15 +703,15 @@ export class CompletionFeature {
703
703
  }
704
704
 
705
705
  private triggerCharacters(): readonly string[] {
706
- const { project, uri } = this.language;
706
+ const { uri } = this.language;
707
707
 
708
- return project.service.providerOptions(METHOD, uri)?.triggerCharacters ?? [];
708
+ return this.language.service.providerOptions(METHOD, uri)?.triggerCharacters ?? [];
709
709
  }
710
710
 
711
711
  private beginRequest(context: CompletionContext, delay: number): void {
712
- const { project, uri } = this.language;
712
+ const { uri } = this.language;
713
713
 
714
- if (!project.service.supports(METHOD, uri)) {
714
+ if (!this.language.service.supports(METHOD, uri)) {
715
715
  return;
716
716
  }
717
717
 
@@ -729,7 +729,7 @@ export class CompletionFeature {
729
729
  let normalized: ReturnType<typeof itemsOf>;
730
730
 
731
731
  try {
732
- const result = await project.service.completion(uri, position, context, { signal: controller.signal });
732
+ const result = await this.language.service.completion(uri, position, context, { signal: controller.signal });
733
733
 
734
734
  if (request !== this.request || this.controller !== controller || controller.signal.aborted) {
735
735
  return;
@@ -839,12 +839,12 @@ export class CompletionFeature {
839
839
  }
840
840
 
841
841
  private scheduleResolve(): void {
842
- const { project, uri } = this.language;
842
+ const { uri } = this.language;
843
843
  this.timers.clear(this.resolveTimer);
844
844
  this.resolving?.abort();
845
845
  const entry = this.ranked[this.active];
846
846
 
847
- if (entry === undefined || this.resolved.has(entry.item) || project.service.providerOptions('completionItem/resolve', uri) === undefined) {
847
+ if (entry === undefined || this.resolved.has(entry.item) || this.language.service.providerOptions('completionItem/resolve', uri) === undefined) {
848
848
  return;
849
849
  }
850
850
 
@@ -854,7 +854,7 @@ export class CompletionFeature {
854
854
  const controller = new AbortController();
855
855
  this.resolving = controller;
856
856
 
857
- void project.service
857
+ void this.language.service
858
858
  .resolveCompletion(uri, entry.item, { signal: controller.signal })
859
859
  .then((item) => {
860
860
  this.resolved.set(entry.item, { ...entry.item, ...item });
@@ -1,7 +1,7 @@
1
1
  import type { KeymapId } from '@adecore/editor/keymap';
2
2
  import type { NavigationKind } from './navigation.ts';
3
3
 
4
- export type MenuRowId = NavigationKind | 'peekReferences' | 'callHierarchy' | 'renameSymbol' | 'codeActions';
4
+ export type MenuRowId = NavigationKind | 'peekReferences' | 'callHierarchy' | 'typeHierarchy' | 'renameSymbol' | 'codeActions';
5
5
 
6
6
  export interface MenuRowSpec {
7
7
  readonly id: MenuRowId;
@@ -19,7 +19,8 @@ const GO_TO: readonly MenuRowSpec[] = [
19
19
 
20
20
  const REFERENCES: readonly MenuRowSpec[] = [
21
21
  { id: 'peekReferences', method: 'textDocument/references', shortcut: 'peekReferences' },
22
- { id: 'callHierarchy', method: 'textDocument/prepareCallHierarchy', shortcut: 'callHierarchy' }
22
+ { id: 'callHierarchy', method: 'textDocument/prepareCallHierarchy', shortcut: 'callHierarchy' },
23
+ { id: 'typeHierarchy', method: 'textDocument/prepareTypeHierarchy', shortcut: 'typeHierarchy' }
23
24
  ];
24
25
 
25
26
  const CHANGES: readonly MenuRowSpec[] = [
@@ -34,8 +34,8 @@ export function followModKey(apple: boolean, onHeld: (held: boolean) => void): (
34
34
 
35
35
  /*
36
36
  * The name under the pointer drawn as a link while Cmd (Ctrl elsewhere) is held and a server knows where it
37
- * is defined, which is what a Mod+click on it follows. The editor only says where the pointer is, so the
38
- * key comes from the window.
37
+ * is defined, which is what a Mod+click on it follows. A document link under the pointer goes first. The
38
+ * editor only says where the pointer is, so the key comes from the window.
39
39
  */
40
40
  export class DefinitionLinkFeature {
41
41
  private readonly language: EditorLanguage;
@@ -90,13 +90,25 @@ export class DefinitionLinkFeature {
90
90
  private update(): void {
91
91
  const { pointer } = this;
92
92
 
93
- if (!this.held || pointer === null || !this.language.project.service.supports('textDocument/definition', this.language.uri)) {
93
+ const link = pointer === null ? null : this.language.links.rangeAt(pointer);
94
+ if (this.held && link !== null) {
95
+ const key = `link:${link.start.line}:${link.start.character}`;
96
+ if (key !== this.shownKey) {
97
+ this.clear();
98
+ this.shownKey = key;
99
+ this.drawn = true;
100
+ this.language.editor.setLink(link);
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (!this.held || pointer === null || !this.language.service.supports('textDocument/definition', this.language.uri)) {
94
106
  this.clear();
95
107
 
96
108
  return;
97
109
  }
98
110
 
99
- const { editor, project, uri } = this.language;
111
+ const { editor, uri } = this.language;
100
112
  const line = lineTextOf(editor, pointer.line);
101
113
  const word = isIdentifierCharacter(line[pointer.character] ?? '') ? wordRangeAt(line, pointer) : null;
102
114
 
@@ -118,7 +130,7 @@ export class DefinitionLinkFeature {
118
130
  const controller = new AbortController();
119
131
  this.lookup = controller;
120
132
 
121
- project.service
133
+ this.language.service
122
134
  .definition(uri, pointer, { signal: controller.signal })
123
135
  .then((result) => {
124
136
  if (request === this.request && locationsOf(result).length > 0) {
@@ -0,0 +1,189 @@
1
+ import type { EditorContentChange, EditorInlayClick, EditorInlayHint } from '@adecore/editor';
2
+ import type { Color, ColorInformation, ColorPresentation, TextEdit } from '@adecore/lsp';
3
+ import { DocumentQuery } from './document-query.ts';
4
+ import type { EditorLanguage } from './editor-language.ts';
5
+ import { realTimers, type Timers } from './timers.ts';
6
+
7
+ const METHOD = 'textDocument/documentColor';
8
+ const OWNER = 'document-colors';
9
+ const DELAY_MS = 500;
10
+ const ID_PREFIX = 'color-';
11
+ const OTHER_COLOR = 'other';
12
+
13
+ /* A color of LSP, each part from 0 to 1, as CSS draws it. */
14
+ export function cssColorOf(color: Color): string {
15
+ const part = (value: number): string => String(Math.round(Math.min(1, Math.max(0, value)) * 1000) / 1000);
16
+ return `color(srgb ${part(color.red)} ${part(color.green)} ${part(color.blue)} / ${part(color.alpha)})`;
17
+ }
18
+
19
+ /* `#rrggbb` of an `<input type="color">` as a color of LSP, opaque. */
20
+ export function colorOfHex(value: string): Color | null {
21
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(value);
22
+ if (match === null) {
23
+ return null;
24
+ }
25
+ return { red: parseInt(match[1]!, 16) / 255, green: parseInt(match[2]!, 16) / 255, blue: parseInt(match[3]!, 16) / 255, alpha: 1 };
26
+ }
27
+
28
+ function hexOf(color: Color): string {
29
+ const part = (value: number): string =>
30
+ Math.round(Math.min(1, Math.max(0, value)) * 255)
31
+ .toString(16)
32
+ .padStart(2, '0');
33
+ return `#${part(color.red)}${part(color.green)}${part(color.blue)}`;
34
+ }
35
+
36
+ /* The edits a presentation makes: its own text edit, or its label over the color, and the edits that come with it. */
37
+ export function presentationEdits(presentation: ColorPresentation, information: ColorInformation): TextEdit[] {
38
+ return [presentation.textEdit ?? { range: information.range, newText: presentation.label }, ...(presentation.additionalTextEdits ?? [])];
39
+ }
40
+
41
+ /*
42
+ * The colors a server finds in the text, such as `#ff0000` in a stylesheet, each with a swatch before it. A press on
43
+ * a swatch lists the ways the server writes that color, so `rgb()` can become `#hex`, and offers a color of the
44
+ * platform's picker, which keeps the way the color was written when the server offers it.
45
+ */
46
+ export class DocumentColorsFeature {
47
+ private readonly language: EditorLanguage;
48
+ private colors: readonly ColorInformation[] = [];
49
+ private readonly query: DocumentQuery<ColorInformation[]>;
50
+ private enabled = true;
51
+
52
+ constructor(language: EditorLanguage, timers: Timers = realTimers) {
53
+ this.language = language;
54
+ this.query = new DocumentQuery(
55
+ language,
56
+ timers,
57
+ DELAY_MS,
58
+ (signal) => (this.enabled && this.supported ? language.service.documentColors!(language.uri, { signal }) : Promise.resolve(null)),
59
+ (colors) => this.show(colors ?? [])
60
+ );
61
+ const { editor } = language;
62
+ if (editor.onInlayClick !== undefined) {
63
+ language.onDispose(editor.onInlayClick((click) => this.pressed(click)));
64
+ }
65
+ language.onDispose(() => editor.setInlayHints([], OWNER));
66
+ }
67
+
68
+ get supported(): boolean {
69
+ const { service } = this.language;
70
+ return service.documentColors !== undefined && service.supports(METHOD, this.language.uri);
71
+ }
72
+
73
+ /* The colors of the current text, empty while an edit waits for the next answer. */
74
+ all(): readonly ColorInformation[] {
75
+ return this.colors;
76
+ }
77
+
78
+ /* Off, there are no swatches; on unless false. */
79
+ configure(settings: { readonly enabled?: boolean }): void {
80
+ this.enabled = settings.enabled !== false;
81
+ if (this.enabled) {
82
+ this.query.run();
83
+ } else {
84
+ this.show([]);
85
+ }
86
+ }
87
+
88
+ /* The ways the server writes a color found in the text, for a menu of the host. */
89
+ async presentations(information: ColorInformation, color: Color = information.color): Promise<ColorPresentation[]> {
90
+ const { service } = this.language;
91
+ if (service.colorPresentations === undefined) {
92
+ return [];
93
+ }
94
+ try {
95
+ return (await service.colorPresentations(this.language.uri, color, information.range)) ?? [];
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /* Writes a presentation in place of the color it was asked for; false when the text moved on since. */
102
+ apply(presentation: ColorPresentation, information: ColorInformation): boolean {
103
+ if (!this.colors.includes(information)) {
104
+ return false;
105
+ }
106
+ const changes: EditorContentChange[] = presentationEdits(presentation, information).map((edit) => ({ range: edit.range, text: edit.newText }));
107
+ return this.language.editor.applyEdits(changes);
108
+ }
109
+
110
+ private show(colors: readonly ColorInformation[]): void {
111
+ this.colors = colors;
112
+ const hints: EditorInlayHint[] = colors.map((information, index) => ({
113
+ id: `${ID_PREFIX}${index}`,
114
+ position: information.range.start,
115
+ label: this.language.project.i18n.t('editor:language.colors.swatch', { color: hexOf(information.color) }),
116
+ swatch: cssColorOf(information.color),
117
+ paddingRight: true
118
+ }));
119
+ this.language.editor.setInlayHints(hints, OWNER);
120
+ }
121
+
122
+ private pressed(click: EditorInlayClick): boolean {
123
+ if (!click.id.startsWith(ID_PREFIX)) {
124
+ return false;
125
+ }
126
+ const information = this.colors[Number(click.id.slice(ID_PREFIX.length))];
127
+ if (information === undefined) {
128
+ return false;
129
+ }
130
+ void this.open(information);
131
+ return true;
132
+ }
133
+
134
+ private async open(information: ColorInformation): Promise<void> {
135
+ const presentations = await this.presentations(information);
136
+ if (!this.colors.includes(information)) {
137
+ return;
138
+ }
139
+ const { pick, project } = this.language;
140
+ const rows = presentations.map((presentation, index) => ({ id: String(index), label: presentation.label, detail: '' }));
141
+ pick.open({
142
+ anchor: information.range.start,
143
+ title: project.i18n.t('editor:language.colors.title'),
144
+ groups: [
145
+ { title: null, rows },
146
+ { title: null, rows: [{ id: OTHER_COLOR, label: project.i18n.t('editor:language.colors.other'), detail: '' }] }
147
+ ],
148
+ accept: (id) => {
149
+ if (id === OTHER_COLOR) {
150
+ this.choose(information, presentations);
151
+ } else {
152
+ this.apply(presentations[Number(id)]!, information);
153
+ }
154
+ }
155
+ });
156
+ }
157
+
158
+ /* The platform's color picker; the new color is written the way the old one was when the server offers that. */
159
+ private choose(information: ColorInformation, before: readonly ColorPresentation[]): void {
160
+ if (typeof document === 'undefined') {
161
+ return;
162
+ }
163
+ const written = this.language.editor.textInRange(information.range);
164
+ const form = Math.max(
165
+ 0,
166
+ before.findIndex((presentation) => presentation.label === written)
167
+ );
168
+ const input = document.createElement('input');
169
+ input.type = 'color';
170
+ input.value = hexOf(information.color);
171
+ input.addEventListener(
172
+ 'change',
173
+ () => {
174
+ const color = colorOfHex(input.value);
175
+ if (color === null) {
176
+ return;
177
+ }
178
+ void this.presentations(information, { ...color, alpha: information.color.alpha }).then((presentations) => {
179
+ const presentation = presentations[form] ?? presentations[0];
180
+ if (presentation !== undefined) {
181
+ this.apply(presentation, information);
182
+ }
183
+ });
184
+ },
185
+ { once: true }
186
+ );
187
+ input.click();
188
+ }
189
+ }
@@ -0,0 +1,129 @@
1
+ import type { EditorPosition, EditorRange } from '@adecore/editor';
2
+ import type { DocumentLink } from '@adecore/lsp';
3
+ import { comparePositions } from './diagnostics-model.ts';
4
+ import { DocumentQuery } from './document-query.ts';
5
+ import type { EditorLanguage } from './editor-language.ts';
6
+ import { realTimers, type Timers } from './timers.ts';
7
+
8
+ const METHOD = 'textDocument/documentLink';
9
+ const RESOLVE = 'documentLink/resolve';
10
+ /* How long after the last edit the links are asked again. */
11
+ const DELAY_MS = 500;
12
+ /* `#L12`, `#12` or `#L12,4`: a line, from one, and a column, from one, the form editors write into a link. */
13
+ const LINE_FRAGMENT = /^#L?(\d+)(?:[,:C](\d+))?$/i;
14
+
15
+ /* Where a link to a file of the editor lands, from the line its fragment names; the start of the file without one. */
16
+ export function linkPosition(target: string): { readonly uri: string; readonly position: EditorPosition } {
17
+ const hash = target.indexOf('#');
18
+ const fragment = hash === -1 ? null : LINE_FRAGMENT.exec(target.slice(hash));
19
+ if (fragment === null) {
20
+ return { uri: target, position: { line: 0, character: 0 } };
21
+ }
22
+ return {
23
+ uri: target.slice(0, hash),
24
+ position: { line: Math.max(0, Number(fragment[1]) - 1), character: Math.max(0, Number(fragment[2] ?? 1) - 1) }
25
+ };
26
+ }
27
+
28
+ /* Whether a target opens in the editor: a file, or a document of a scheme a server serves. A web address opens outside. */
29
+ function opensInEditor(target: string): boolean {
30
+ return !/^(https?|mailto|ftp):/i.test(target);
31
+ }
32
+
33
+ /*
34
+ * The links a server finds in the text, such as an import path or a web address in a comment. Mod+click on one
35
+ * follows it, and while Mod is held the link under the pointer is drawn as one (`DefinitionLinkFeature` draws it).
36
+ * A link without a target asks the server for it first.
37
+ */
38
+ export class DocumentLinksFeature {
39
+ private readonly language: EditorLanguage;
40
+ private links: readonly DocumentLink[] = [];
41
+ private readonly query: DocumentQuery<DocumentLink[]>;
42
+
43
+ constructor(language: EditorLanguage, timers: Timers = realTimers) {
44
+ this.language = language;
45
+ this.query = new DocumentQuery(
46
+ language,
47
+ timers,
48
+ DELAY_MS,
49
+ (signal) => (this.supported ? language.service.documentLinks!(language.uri, { signal }) : Promise.resolve(null)),
50
+ (links) => {
51
+ this.links = links ?? [];
52
+ }
53
+ );
54
+ const off = language.editor.onClick((click) => {
55
+ if (!click.mod || click.alt || click.shift) {
56
+ return false;
57
+ }
58
+ const link = this.linkAt(click.position);
59
+ if (link === null) {
60
+ return false;
61
+ }
62
+ void this.follow(link);
63
+ return true;
64
+ });
65
+ language.onDispose(off);
66
+ }
67
+
68
+ get supported(): boolean {
69
+ const { service } = this.language;
70
+ return service.documentLinks !== undefined && service.supports(METHOD, this.language.uri);
71
+ }
72
+
73
+ /* The links of the current text, empty while an edit waits for the next answer. */
74
+ all(): readonly DocumentLink[] {
75
+ return this.links;
76
+ }
77
+
78
+ /* The link whose range holds `position`, its end left out. */
79
+ linkAt(position: EditorPosition): DocumentLink | null {
80
+ return this.links.find((link) => comparePositions(link.range.start, position) <= 0 && comparePositions(position, link.range.end) < 0) ?? null;
81
+ }
82
+
83
+ /* The range of the link at `position`, which the pointer draws as a link while Mod is held. */
84
+ rangeAt(position: EditorPosition): EditorRange | null {
85
+ return this.linkAt(position)?.range ?? null;
86
+ }
87
+
88
+ refresh(): void {
89
+ this.query.run();
90
+ }
91
+
92
+ /* Opens what a link points at; false when it has no target, even after asking. */
93
+ async follow(link: DocumentLink): Promise<boolean> {
94
+ const target = link.target ?? (await this.resolve(link))?.target;
95
+ if (target === undefined) {
96
+ return false;
97
+ }
98
+ openTarget(this.language, target);
99
+ return true;
100
+ }
101
+
102
+ private async resolve(link: DocumentLink): Promise<DocumentLink | null> {
103
+ const { service } = this.language;
104
+ if (service.resolveDocumentLink === undefined || service.providerOptions(RESOLVE, this.language.uri)?.resolveProvider !== true) {
105
+ return null;
106
+ }
107
+ try {
108
+ return await service.resolveDocumentLink(this.language.uri, link);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ }
114
+
115
+ /* Opens a file or a document of a server in the editor, and a web address through the host. */
116
+ export function openTarget(language: EditorLanguage, target: string, selection?: EditorRange): void {
117
+ if (!opensInEditor(target)) {
118
+ const { host } = language.project;
119
+ if (host.openExternal !== undefined) {
120
+ host.openExternal(target);
121
+ } else if (typeof window !== 'undefined') {
122
+ window.open(target, '_blank', 'noopener,noreferrer');
123
+ }
124
+ return;
125
+ }
126
+ const { uri, position } = linkPosition(target);
127
+ const range = selection ?? { start: position, end: position };
128
+ language.goTo({ uri, range });
129
+ }
@@ -0,0 +1,71 @@
1
+ import type { EditorLanguage } from './editor-language.ts';
2
+ import type { Timers } from './timers.ts';
3
+
4
+ /*
5
+ * Asks the servers for something of the whole document: once it is open, a moment after the last edit, and
6
+ * whenever what the servers offer changes. An edit clears the answer at once, since its ranges point into a
7
+ * text that is gone; an answer for an older text is dropped.
8
+ */
9
+ export class DocumentQuery<Value> {
10
+ private readonly language: EditorLanguage;
11
+ private readonly timers: Timers;
12
+ private readonly delay: number;
13
+ private readonly ask: (signal: AbortSignal) => Promise<Value | null>;
14
+ private readonly apply: (value: Value | null) => void;
15
+ private timer: unknown = null;
16
+ private controller: AbortController | null = null;
17
+ private generation = 0;
18
+
19
+ constructor(
20
+ language: EditorLanguage,
21
+ timers: Timers,
22
+ delay: number,
23
+ ask: (signal: AbortSignal) => Promise<Value | null>,
24
+ apply: (value: Value | null) => void
25
+ ) {
26
+ this.language = language;
27
+ this.timers = timers;
28
+ this.delay = delay;
29
+ this.ask = ask;
30
+ this.apply = apply;
31
+ const offs = [
32
+ language.onProvidersChanged(() => this.run()),
33
+ language.editor.onTextChange(() => {
34
+ this.cancel();
35
+ this.apply(null);
36
+ this.timer = this.timers.set(() => this.run(), this.delay);
37
+ })
38
+ ];
39
+ language.onDispose(() => {
40
+ this.cancel();
41
+ for (const off of offs) {
42
+ off();
43
+ }
44
+ });
45
+ }
46
+
47
+ /* Asks again now, as a refresh of the host does. */
48
+ run(): void {
49
+ this.cancel();
50
+ const generation = this.generation;
51
+ const controller = new AbortController();
52
+ this.controller = controller;
53
+ this.ask(controller.signal).then(
54
+ (value) => {
55
+ if (generation === this.generation && !this.language.isDisposed) {
56
+ this.apply(value);
57
+ }
58
+ },
59
+ // A server that fails or refuses answers nothing; the next edit asks again.
60
+ () => undefined
61
+ );
62
+ }
63
+
64
+ private cancel(): void {
65
+ this.generation++;
66
+ this.timers.clear(this.timer);
67
+ this.timer = null;
68
+ this.controller?.abort();
69
+ this.controller = null;
70
+ }
71
+ }