@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/PickPopup.tsx CHANGED
@@ -10,7 +10,34 @@ import type { PickPreview, PickView } from './popups.ts';
10
10
  const CARD =
11
11
  'fixed top-0 left-0 z-(--z-popup) flex w-[420px] flex-col overflow-hidden rounded-lg border border-border bg-surface-raised text-text shadow-(--float-shadow)';
12
12
 
13
+ /* The label with the part a query matched marked, whatever the case. */
14
+ function MatchedLabel({ label, query }: { label: string; query: string | null }) {
15
+ const start = query === null || query === '' ? -1 : label.toLowerCase().indexOf(query.toLowerCase());
16
+
17
+ if (query === null || start < 0) {
18
+ return label;
19
+ }
20
+
21
+ return (
22
+ <>
23
+ {label.slice(0, start)}
24
+ <mark className="bg-transparent font-semibold text-accent">{label.slice(start, start + query.length)}</mark>
25
+ {label.slice(start + query.length)}
26
+ </>
27
+ );
28
+ }
29
+
13
30
  function PreviewLines({ preview }: { preview: PickPreview }) {
31
+ if (preview.kind === 'text') {
32
+ return (
33
+ <div className="max-h-48 overflow-auto border-t border-border p-2 font-mono text-code whitespace-pre">
34
+ {preview.added.map((line, index) => (
35
+ <div key={index}>{line === '' ? ' ' : line}</div>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
40
+
14
41
  return (
15
42
  <div className="flex flex-col gap-px border-t border-border p-1 font-mono text-code">
16
43
  {preview.removed.map((line, index) => (
@@ -89,7 +116,12 @@ export function PickPopup({ language, view, rect }: { language: EditorLanguage;
89
116
  onPointerDown={(event) => event.preventDefault()}
90
117
  {...keepFocusOutOfList(() => language.editor.focus())}
91
118
  >
92
- {view.title !== null && <div className="border-b border-border px-3 py-1.5 text-xs text-text-muted">{view.title}</div>}
119
+ {(view.title !== null || view.query !== null) && (
120
+ <div className="flex items-baseline gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
121
+ <span className="min-w-0 truncate">{view.title}</span>
122
+ {view.query !== null && view.query !== '' && <span className="ml-auto shrink-0 font-mono text-text">{view.query}</span>}
123
+ </div>
124
+ )}
93
125
  <div id={listId} role="listbox" className="min-h-0 flex-1 overflow-y-auto p-1">
94
126
  {view.groups.map((group, groupIndex) => (
95
127
  <div key={group.title ?? groupIndex} role="group" className="flex flex-col">
@@ -102,12 +134,15 @@ export function PickPopup({ language, view, rect }: { language: EditorLanguage;
102
134
  type="button"
103
135
  role="option"
104
136
  tabIndex={-1}
105
- aria-selected={row.id === view.active}
137
+ aria-selected={row.id === view.active || view.chosen.includes(row.id)}
106
138
  data-active={row.id === view.active}
107
- className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-xs cursor-row"
108
- onClick={() => language.pick.choose(row.id)}
139
+ data-chosen={view.chosen.includes(row.id)}
140
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-xs cursor-row data-[chosen=true]:bg-accent/15"
141
+ onClick={(event) => language.pick.choose(row.id, { toggle: event.metaKey || event.ctrlKey, range: event.shiftKey })}
109
142
  >
110
- <span className={row.path ? 'shrink-0' : 'min-w-0 truncate'}>{row.label}</span>
143
+ <span className={row.path ? 'shrink-0' : 'min-w-0 truncate'}>
144
+ <MatchedLabel label={row.label} query={view.query} />
145
+ </span>
111
146
  {row.path ? (
112
147
  <PathText path={row.detail} className="ml-auto font-mono text-text-faint" />
113
148
  ) : (
@@ -0,0 +1,64 @@
1
+ import { useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import type { EditorRect } from '@adecore/editor';
4
+ import { Input } from '@adecore/ui';
5
+ import { AnchoredPopup } from './AnchoredPopup.tsx';
6
+ import type { EditorLanguage } from './editor-language.ts';
7
+ import type { WrapView } from './popups.ts';
8
+
9
+ /* The field for the abbreviation a selection is wrapped in: Enter wraps, Escape or leaving the field cancels. */
10
+ export function WrapCard({ language, view, rect }: { language: EditorLanguage; view: WrapView; rect: EditorRect }) {
11
+ const { t } = useTranslation('editor');
12
+ const input = useRef<HTMLInputElement>(null);
13
+ const [value, setValue] = useState('');
14
+
15
+ useLayoutEffect(() => {
16
+ input.current?.focus();
17
+ }, []);
18
+
19
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
20
+ if (event.nativeEvent.isComposing) {
21
+ return;
22
+ }
23
+ if (event.key === 'Escape') {
24
+ event.preventDefault();
25
+ event.stopPropagation();
26
+ language.markup.cancelWrap();
27
+ language.editor.focus();
28
+ } else if (event.key === 'Enter') {
29
+ event.preventDefault();
30
+ event.stopPropagation();
31
+ if (value.trim() !== '') {
32
+ language.markup.submitWrap(value);
33
+ }
34
+ }
35
+ }
36
+
37
+ return (
38
+ <AnchoredPopup rect={rect} placement={{ prefer: 'above' }}>
39
+ <div className="flex w-[280px] flex-col gap-1.5 p-2">
40
+ <Input
41
+ ref={input}
42
+ size="sm"
43
+ mono
44
+ value={value}
45
+ spellCheck={false}
46
+ autoComplete="off"
47
+ aria-label={t('language.markup.wrap')}
48
+ aria-invalid={view.error !== null}
49
+ placeholder={t('language.markup.placeholder')}
50
+ onChange={(event) => setValue(event.target.value)}
51
+ onKeyDown={onKeyDown}
52
+ onBlur={() => language.markup.cancelWrap()}
53
+ />
54
+ {view.error !== null ? (
55
+ <div role="alert" className="text-xs text-status-error">
56
+ {view.error}
57
+ </div>
58
+ ) : (
59
+ <div className="text-xs text-text-muted">↵ {t('language.markup.apply')}</div>
60
+ )}
61
+ </div>
62
+ </AnchoredPopup>
63
+ );
64
+ }
@@ -1,7 +1,12 @@
1
- import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, Range } from '@adecore/lsp';
1
+ import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, Range, TypeHierarchyItem } from '@adecore/lsp';
2
2
 
3
- /* Callers of a symbol, or the symbols it calls. */
4
- export type CallDirection = 'incoming' | 'outgoing';
3
+ /* Callers of a symbol or the symbols it calls; or the super and sub types of a type, which the same tree draws. */
4
+ export type CallDirection = 'incoming' | 'outgoing' | 'supertypes' | 'subtypes';
5
+
6
+ /* Whether a direction walks a type hierarchy and not the calls. */
7
+ export function isTypeDirection(direction: CallDirection): direction is 'supertypes' | 'subtypes' {
8
+ return direction === 'supertypes' || direction === 'subtypes';
9
+ }
5
10
 
6
11
  /* The most calls of one level that are drawn at once; the rest wait behind a row that shows more. */
7
12
  export const CALL_LEVEL_LIMIT = 50;
@@ -103,6 +108,23 @@ export function childrenOf(direction: CallDirection, parent: CallNode, calls: re
103
108
  }));
104
109
  }
105
110
 
111
+ /* The children of a node made from the types a server returned; a type has no call sites, so a row goes to the type itself. */
112
+ export function typeChildrenOf(parent: CallNode, items: readonly TypeHierarchyItem[]): CallNode[] {
113
+ return items.map((item, index) => ({
114
+ id: `${parent.id}.${index}`,
115
+ parent: parent.id,
116
+ level: parent.level + 1,
117
+ item,
118
+ sites: [],
119
+ siteUri: null,
120
+ expanded: false,
121
+ load: 'idle',
122
+ error: null,
123
+ children: [],
124
+ shown: 0
125
+ }));
126
+ }
127
+
106
128
  /* The lines the tree draws: every root, and under an expanded node its status, its calls and the row that shows more. */
107
129
  export function rowsOf(nodes: ReadonlyMap<string, CallNode>, roots: readonly string[]): CallRow[] {
108
130
  const rows: CallRow[] = [];
@@ -5,12 +5,14 @@ import type { EditorLanguage } from './editor-language.ts';
5
5
  import {
6
6
  CALL_LEVEL_LIMIT,
7
7
  childrenOf,
8
+ isTypeDirection,
8
9
  moreIdOf,
9
10
  rootsOf,
10
11
  rowsOf,
11
12
  statusIdOf,
12
13
  stepsOf,
13
14
  targetOf,
15
+ typeChildrenOf,
14
16
  type CallDirection,
15
17
  type CallNode,
16
18
  type CallRow
@@ -23,6 +25,7 @@ import { realTimers, revealRow, type Timers } from './timers.ts';
23
25
 
24
26
  const TOAST_ID = 'language-call-hierarchy';
25
27
  const PREPARE = 'textDocument/prepareCallHierarchy';
28
+ const PREPARE_TYPES = 'textDocument/prepareTypeHierarchy';
26
29
 
27
30
  /* The row the panel stands in is as tall as a peek's, plus the segmented control that is a pixel taller than the peek's title bar. */
28
31
  export const CALL_HIERARCHY_HEIGHT = PEEK_HEIGHT + 2;
@@ -42,6 +45,7 @@ export class CallHierarchyFeature {
42
45
  private nodes = new Map<string, CallNode>();
43
46
  private roots: string[] = [];
44
47
  private prepared: CallHierarchyItem[] = [];
48
+ private position: EditorPosition | null = null;
45
49
  private direction: CallDirection = 'incoming';
46
50
  private controller = new AbortController();
47
51
 
@@ -58,11 +62,13 @@ export class CallHierarchyFeature {
58
62
 
59
63
  const offs = [
60
64
  language.onKey((event) => {
61
- if (isShortcut(this.language.shortcuts.callHierarchy, event)) {
62
- if (this.isOpen) {
63
- this.setDirection(this.direction === 'incoming' ? 'outgoing' : 'incoming');
65
+ const calls = isShortcut(this.language.shortcuts.callHierarchy, event);
66
+ if (calls || isShortcut(this.language.shortcuts.typeHierarchy, event)) {
67
+ const [first, second]: CallDirection[] = calls ? ['incoming', 'outgoing'] : ['supertypes', 'subtypes'];
68
+ if (this.isOpen && isTypeDirection(this.direction) !== calls) {
69
+ this.setDirection(this.direction === first ? second : first);
64
70
  } else {
65
- void this.open();
71
+ void this.open(undefined, first);
66
72
  }
67
73
 
68
74
  return true;
@@ -88,8 +94,7 @@ export class CallHierarchyFeature {
88
94
 
89
95
  /* Whether a server of this document answers the three requests the tree is made of. */
90
96
  get supported(): boolean {
91
- const { project, uri } = this.language;
92
- const { service } = project;
97
+ const { service, uri } = this.language;
93
98
 
94
99
  return (
95
100
  service.prepareCallHierarchy !== undefined &&
@@ -99,12 +104,28 @@ export class CallHierarchyFeature {
99
104
  );
100
105
  }
101
106
 
102
- /* Opens the tree for the symbol at the position, on its callers unless told otherwise. */
107
+ /* Whether a server of this document answers the three requests of the type hierarchy. */
108
+ get typesSupported(): boolean {
109
+ const { service, uri } = this.language;
110
+
111
+ return (
112
+ service.prepareTypeHierarchy !== undefined &&
113
+ service.supertypes !== undefined &&
114
+ service.subtypes !== undefined &&
115
+ service.supports(PREPARE_TYPES, uri)
116
+ );
117
+ }
118
+
119
+ /*
120
+ * Opens the tree for the symbol at the position, on its callers unless told otherwise; `supertypes` or `subtypes`
121
+ * open the type hierarchy of the type at the position.
122
+ */
103
123
  async open(position: EditorPosition = this.language.editor.getCaret(), direction: CallDirection = 'incoming'): Promise<void> {
104
- const { editor, project, uri } = this.language;
124
+ const { editor, service, uri } = this.language;
125
+ const types = isTypeDirection(direction);
105
126
 
106
- if (!this.supported) {
107
- this.tell(this.say('unavailable'));
127
+ if (!(types ? this.typesSupported : this.supported)) {
128
+ this.tell(this.say(types ? 'typesUnavailable' : 'unavailable'));
108
129
 
109
130
  return;
110
131
  }
@@ -114,10 +135,12 @@ export class CallHierarchyFeature {
114
135
  let items: CallHierarchyItem[] | null;
115
136
 
116
137
  try {
117
- items = await project.service.prepareCallHierarchy!(uri, position, { signal: this.controller.signal });
138
+ const options = { signal: this.controller.signal };
139
+
140
+ items = types ? await service.prepareTypeHierarchy!(uri, position, options) : await service.prepareCallHierarchy!(uri, position, options);
118
141
  } catch (error) {
119
142
  if (epoch === this.epoch && !(error instanceof StaleResultError)) {
120
- this.tell(this.say('failed', { message: messageOf(error) }));
143
+ this.tell(this.say(types ? 'typesFailed' : 'failed', { message: messageOf(error) }));
121
144
  }
122
145
 
123
146
  return;
@@ -128,13 +151,14 @@ export class CallHierarchyFeature {
128
151
  }
129
152
 
130
153
  if (items === null || items.length === 0) {
131
- this.tell(this.say('none'));
154
+ this.tell(this.say(types ? 'typesNone' : 'none'));
132
155
 
133
156
  return;
134
157
  }
135
158
 
136
159
  this.language.peek.close();
137
160
  this.prepared = items;
161
+ this.position = position;
138
162
  this.direction = direction;
139
163
  this.seed();
140
164
  const active = this.roots[0]!;
@@ -171,7 +195,7 @@ export class CallHierarchyFeature {
171
195
  this.language.editor.focus();
172
196
  }
173
197
 
174
- /* Starts the tree over from the same symbols for the other direction. */
198
+ /* Starts the tree over from the same symbols for the other direction; a switch between calls and types prepares the symbol again. */
175
199
  setDirection(direction: CallDirection): void {
176
200
  const view = this.language.popups.getState().callHierarchy;
177
201
 
@@ -179,6 +203,12 @@ export class CallHierarchyFeature {
179
203
  return;
180
204
  }
181
205
 
206
+ if (isTypeDirection(direction) !== isTypeDirection(view.direction)) {
207
+ void this.open(this.position ?? undefined, direction);
208
+
209
+ return;
210
+ }
211
+
182
212
  this.reset();
183
213
  this.direction = direction;
184
214
  this.seed();
@@ -331,22 +361,19 @@ export class CallHierarchyFeature {
331
361
  return;
332
362
  }
333
363
 
334
- const { service, i18n } = this.language.project;
335
- const { uri, editor } = this.language;
364
+ const { i18n } = this.language.project;
365
+ const { editor } = this.language;
336
366
  const { direction, epoch } = this;
337
367
  const options = { signal: this.controller.signal, parallel: true };
338
368
  this.patch(id, { load: 'loading', error: null });
339
369
 
340
370
  try {
341
- const calls =
342
- direction === 'incoming' ? await service.incomingCalls!(uri, node.item, options) : await service.outgoingCalls!(uri, node.item, options);
371
+ const children = await this.childrenOf(direction, node, options);
343
372
 
344
373
  if (epoch !== this.epoch) {
345
374
  return;
346
375
  }
347
376
 
348
- const children = childrenOf(direction, node, calls ?? []);
349
-
350
377
  for (const child of children) {
351
378
  this.nodes.set(child.id, child);
352
379
  }
@@ -368,6 +395,21 @@ export class CallHierarchyFeature {
368
395
  }
369
396
  }
370
397
 
398
+ private async childrenOf(direction: CallDirection, node: CallNode, options: { signal: AbortSignal; parallel: boolean }): Promise<CallNode[]> {
399
+ const { service, uri } = this.language;
400
+
401
+ switch (direction) {
402
+ case 'incoming':
403
+ return childrenOf(direction, node, (await service.incomingCalls!(uri, node.item, options)) ?? []);
404
+ case 'outgoing':
405
+ return childrenOf(direction, node, (await service.outgoingCalls!(uri, node.item, options)) ?? []);
406
+ case 'supertypes':
407
+ return typeChildrenOf(node, (await service.supertypes!(uri, node.item, options)) ?? []);
408
+ case 'subtypes':
409
+ return typeChildrenOf(node, (await service.subtypes!(uri, node.item, options)) ?? []);
410
+ }
411
+ }
412
+
371
413
  private async preview(node: CallNode, site: number): Promise<void> {
372
414
  const { project } = this.language;
373
415
  const epoch = ++this.previewEpoch;
@@ -0,0 +1,102 @@
1
+ /* One copy or cut, as the editors of a project made it. */
2
+ export interface ClipboardEntry {
3
+ readonly text: string;
4
+ /* The copy took the lines of bare carets, which paste back above the lines of bare carets. */
5
+ readonly wholeLines: boolean;
6
+ readonly at: number;
7
+ }
8
+
9
+ export interface ClipboardHistoryOptions {
10
+ /* How many entries the history keeps; 100. */
11
+ readonly limit?: number;
12
+ /* The UTF-16 length all entries may take together; 10,000,000. The oldest go first, and the newest stays even when it alone is longer. */
13
+ readonly maxLength?: number;
14
+ /* What a host kept, newest first. */
15
+ readonly entries?: readonly ClipboardEntry[];
16
+ /* Told after every change, for a host that keeps the entries. */
17
+ onChange?(entries: readonly ClipboardEntry[]): void;
18
+ readonly clock?: () => number;
19
+ }
20
+
21
+ /* The texts copied or cut in the editors of a project, newest first, for Paste from History. */
22
+ export class ClipboardHistory {
23
+ private readonly limit: number;
24
+ private readonly maxLength: number;
25
+ private readonly onChange: ((entries: readonly ClipboardEntry[]) => void) | undefined;
26
+ private readonly clock: () => number;
27
+ private readonly listeners = new Set<() => void>();
28
+ private list: readonly ClipboardEntry[];
29
+
30
+ constructor(options: ClipboardHistoryOptions = {}) {
31
+ this.limit = options.limit ?? 100;
32
+ this.maxLength = options.maxLength ?? 10_000_000;
33
+ this.onChange = options.onChange;
34
+ this.clock = options.clock ?? Date.now;
35
+ this.list = this.trimmed(options.entries ?? []);
36
+ }
37
+
38
+ /* Newest first. */
39
+ get entries(): readonly ClipboardEntry[] {
40
+ return this.list;
41
+ }
42
+
43
+ /* Puts a copy on top. The same text again moves up instead of being kept twice, and nothing is kept for an empty text. */
44
+ add(text: string, options: { readonly wholeLines?: boolean } = {}): void {
45
+ if (text === '') {
46
+ return;
47
+ }
48
+
49
+ const entry = { text, wholeLines: options.wholeLines === true, at: this.clock() };
50
+
51
+ this.change([entry, ...this.list.filter((other) => other.text !== text)]);
52
+ }
53
+
54
+ remove(entry: ClipboardEntry): void {
55
+ if (this.list.includes(entry)) {
56
+ this.change(this.list.filter((other) => other !== entry));
57
+ }
58
+ }
59
+
60
+ /* Moves an entry to the top, as choosing it does. */
61
+ promote(entry: ClipboardEntry): void {
62
+ if (this.list.includes(entry) && this.list[0] !== entry) {
63
+ this.change([entry, ...this.list.filter((other) => other !== entry)]);
64
+ }
65
+ }
66
+
67
+ clear(): void {
68
+ if (this.list.length > 0) {
69
+ this.change([]);
70
+ }
71
+ }
72
+
73
+ subscribe(listener: () => void): () => void {
74
+ this.listeners.add(listener);
75
+
76
+ return () => this.listeners.delete(listener);
77
+ }
78
+
79
+ private change(entries: readonly ClipboardEntry[]): void {
80
+ this.list = this.trimmed(entries);
81
+ this.onChange?.(this.list);
82
+ this.listeners.forEach((listener) => listener());
83
+ }
84
+
85
+ /* Keeps the newest entries that fit the limit and the total length. */
86
+ private trimmed(entries: readonly ClipboardEntry[]): readonly ClipboardEntry[] {
87
+ const kept: ClipboardEntry[] = [];
88
+ let length = 0;
89
+
90
+ for (const entry of entries.slice(0, this.limit)) {
91
+ length += entry.text.length;
92
+
93
+ if (kept.length > 0 && length > this.maxLength) {
94
+ break;
95
+ }
96
+
97
+ kept.push(entry);
98
+ }
99
+
100
+ return kept;
101
+ }
102
+ }
@@ -13,7 +13,7 @@ import {
13
13
  type ActionEntry,
14
14
  type ActionGroup
15
15
  } from './code-actions-model.ts';
16
- import type { Problem } from './diagnostics-model.ts';
16
+ import { comparePositions, type Problem } from './diagnostics-model.ts';
17
17
  import type { EditorLanguage } from './editor-language.ts';
18
18
  import type { PickGroup, PickPreview } from './popups.ts';
19
19
  import { Refresher } from './refresher.ts';
@@ -122,7 +122,7 @@ export class CodeActionsFeature {
122
122
  }
123
123
 
124
124
  get supported(): boolean {
125
- return this.language.project.service.supports(METHOD, this.language.uri);
125
+ return this.language.service.supports(METHOD, this.language.uri);
126
126
  }
127
127
 
128
128
  /* The list under the caret, or under the selection's start. */
@@ -162,16 +162,16 @@ export class CodeActionsFeature {
162
162
  }
163
163
 
164
164
  async formatDocument(): Promise<void> {
165
- const { editor, project, uri } = this.language;
165
+ const { editor, uri } = this.language;
166
166
 
167
- if (!project.service.supports('textDocument/formatting', uri)) {
167
+ if (!this.language.service.supports('textDocument/formatting', uri)) {
168
168
  this.tell('error', this.say('noFormatter'));
169
169
 
170
170
  return;
171
171
  }
172
172
 
173
173
  try {
174
- const edits = await project.service.formatting(uri, { ...editor.getIndentation() });
174
+ const edits = await this.language.service.formatting(uri, { ...editor.getIndentation() });
175
175
 
176
176
  if (edits !== null && edits.length > 0 && !editor.applyEdits(edits.map((edit) => ({ range: edit.range, text: edit.newText })))) {
177
177
  this.tell('error', this.say('readOnly'));
@@ -181,6 +181,43 @@ export class CodeActionsFeature {
181
181
  }
182
182
  }
183
183
 
184
+ /* Whether a server formats a part of this document, so `formatSelection` has something to ask. */
185
+ get formatsSelection(): boolean {
186
+ return this.language.service.supports('textDocument/rangeFormatting', this.language.uri);
187
+ }
188
+
189
+ /*
190
+ * Formats what is selected, every selection in one request when the server formats several ranges at once and
191
+ * one by one otherwise; the whole document when nothing is selected.
192
+ */
193
+ async formatSelection(): Promise<void> {
194
+ const { editor, uri } = this.language;
195
+ const ranges = editor.getSelections().filter((range) => comparePositions(range.start, range.end) !== 0);
196
+ if (ranges.length === 0) {
197
+ await this.formatDocument();
198
+ return;
199
+ }
200
+ if (!this.formatsSelection) {
201
+ this.tell('error', this.say('noFormatter'));
202
+ return;
203
+ }
204
+ const { service } = this.language;
205
+ const formatting = { ...editor.getIndentation() };
206
+ try {
207
+ const edits =
208
+ service.rangesFormatting !== undefined && service.supports('textDocument/rangesFormatting', uri)
209
+ ? await service.rangesFormatting(uri, ranges, formatting)
210
+ : (await Promise.all(ranges.map((range) => service.rangeFormatting(uri, range, formatting, { parallel: true })))).flatMap(
211
+ (list) => list ?? []
212
+ );
213
+ if (edits !== null && edits.length > 0 && !editor.applyEdits(edits.map((edit) => ({ range: edit.range, text: edit.newText })))) {
214
+ this.tell('error', this.say('readOnly'));
215
+ }
216
+ } catch (error) {
217
+ this.fail(error);
218
+ }
219
+ }
220
+
184
221
  /* The actions of one kind for the selection, as entries a menu can list and `apply` runs. */
185
222
  list(only: readonly string[]): Promise<ActionEntry[] | null> {
186
223
  if (!this.supported) {
@@ -247,7 +284,7 @@ export class CodeActionsFeature {
247
284
 
248
285
  /* Asks the servers; null when they could not answer, with the reason said aloud for a request a person made. */
249
286
  private async request(range: EditorRange, triggerKind: number, options: RequestOptions = {}): Promise<ActionEntry[] | null> {
250
- const { project, uri, diagnostics } = this.language;
287
+ const { uri, diagnostics } = this.language;
251
288
  const { only, signal, order } = options;
252
289
 
253
290
  try {
@@ -264,7 +301,7 @@ export class CodeActionsFeature {
264
301
  triggerKind: triggerKind as 1 | 2
265
302
  };
266
303
 
267
- const entries = actionsOf(await project.service.codeActions(uri, range, context, { signal }), order);
304
+ const entries = actionsOf(await this.language.service.codeActions(uri, range, context, { signal }), order);
268
305
 
269
306
  // A server may answer with more than the kinds asked for.
270
307
  return only ? entries.filter((entry) => only.some((kind) => entry.action.kind === kind || entry.action.kind?.startsWith(`${kind}.`))) : entries;
@@ -329,13 +366,13 @@ export class CodeActionsFeature {
329
366
  return known;
330
367
  }
331
368
 
332
- const { project, uri } = this.language;
369
+ const { uri } = this.language;
333
370
  const { action } = entry;
334
371
  let result = action;
335
372
 
336
- if (action.edit === undefined && project.service.providerOptions(METHOD, uri)?.resolveProvider === true) {
373
+ if (action.edit === undefined && this.language.service.providerOptions(METHOD, uri)?.resolveProvider === true) {
337
374
  try {
338
- result = await project.service.resolveCodeAction(uri, action, { signal });
375
+ result = await this.language.service.resolveCodeAction(uri, action, { signal });
339
376
  } catch (error) {
340
377
  if (isRefusal(error)) {
341
378
  throw new ActionRefused((error as Error).message);
@@ -427,7 +464,7 @@ export class CodeActionsFeature {
427
464
  if (action.command?.command === INLINE_RENAME_COMMAND) {
428
465
  await this.startRename(action.command);
429
466
  } else if (action.command !== undefined) {
430
- await project.service.executeCommand(uri, action.command);
467
+ await this.language.service.executeCommand(uri, action.command);
431
468
  }
432
469
  } catch (error) {
433
470
  this.fail(error);
@@ -149,7 +149,7 @@ export class CodeVisionFeature {
149
149
  }
150
150
 
151
151
  private get canCount(): boolean {
152
- return this.settings.usages && this.language.project.service.supports(REFERENCES, this.language.uri);
152
+ return this.settings.usages && this.language.service.supports(REFERENCES, this.language.uri);
153
153
  }
154
154
 
155
155
  /* Queues the declarations in view whose count is not from the current text, nearest the top of the view first. */
@@ -180,13 +180,13 @@ export class CodeVisionFeature {
180
180
  }
181
181
 
182
182
  private async count(declaration: CodeVisionDeclaration): Promise<void> {
183
- const { project, uri } = this.language;
183
+ const { uri } = this.language;
184
184
  const controller = new AbortController();
185
185
  this.running.set(declaration.id, controller);
186
186
  const epoch = this.epoch;
187
187
 
188
188
  try {
189
- const locations = await project.service.references(uri, declaration.position, false, { signal: controller.signal, parallel: true });
189
+ const locations = await this.language.service.references(uri, declaration.position, false, { signal: controller.signal, parallel: true });
190
190
 
191
191
  if (!this.disposed && epoch === this.epoch) {
192
192
  this.counts.set(declaration.id, { count: distinctPlaces(locations ?? []), epoch });