@adecore/editor-react 0.23.1 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/AnchoredPopup.d.ts +2 -1
  2. package/dist/AnchoredPopup.js +2 -2
  3. package/dist/CodeAuthorsCard.d.ts +4 -0
  4. package/dist/CodeAuthorsCard.js +8 -4
  5. package/dist/CompletionPopup.js +7 -3
  6. package/dist/EditorView.d.ts +8 -3
  7. package/dist/EditorView.js +79 -9
  8. package/dist/HoverCard.d.ts +3 -1
  9. package/dist/HoverCard.js +2 -2
  10. package/dist/LanguagePopups.d.ts +8 -4
  11. package/dist/LanguagePopups.js +4 -4
  12. package/dist/PickPopup.js +2 -1
  13. package/dist/RenameCard.js +10 -8
  14. package/dist/RenderedDocs.d.ts +10 -4
  15. package/dist/RenderedDocs.js +10 -6
  16. package/dist/call-hierarchy.js +15 -14
  17. package/dist/code-actions.js +5 -2
  18. package/dist/completion-model.js +3 -1
  19. package/dist/completion.d.ts +4 -2
  20. package/dist/completion.js +49 -27
  21. package/dist/diagnostics-model.d.ts +1 -0
  22. package/dist/diagnostics-model.js +1 -1
  23. package/dist/diagnostics.d.ts +1 -0
  24. package/dist/diagnostics.js +28 -10
  25. package/dist/diff-overlays.js +2 -2
  26. package/dist/editor-language.d.ts +3 -1
  27. package/dist/editor-language.js +10 -0
  28. package/dist/format-on-type.js +2 -3
  29. package/dist/highlights.js +3 -2
  30. package/dist/history.js +3 -2
  31. package/dist/holder.d.ts +3 -3
  32. package/dist/holder.js +2 -1
  33. package/dist/host-types.d.ts +3 -0
  34. package/dist/hover.d.ts +5 -0
  35. package/dist/hover.js +13 -5
  36. package/dist/index.d.ts +6 -2
  37. package/dist/index.js +3 -1
  38. package/dist/key-priority.d.ts +23 -0
  39. package/dist/key-priority.js +39 -0
  40. package/dist/locales/en.json +2 -1
  41. package/dist/locales/nl.json +2 -1
  42. package/dist/models.d.ts +2 -2
  43. package/dist/models.js +2 -2
  44. package/dist/navigation.js +3 -2
  45. package/dist/peek.js +3 -2
  46. package/dist/pick.js +2 -1
  47. package/dist/popup-focus.d.ts +5 -0
  48. package/dist/popup-focus.js +12 -0
  49. package/dist/popups.d.ts +1 -1
  50. package/dist/project-language.js +5 -4
  51. package/dist/project-problems.d.ts +10 -1
  52. package/dist/project-problems.js +34 -10
  53. package/dist/proposal-model.d.ts +0 -1
  54. package/dist/proposal-model.js +0 -8
  55. package/dist/rename-model.d.ts +2 -1
  56. package/dist/rename-model.js +57 -0
  57. package/dist/rename.d.ts +1 -0
  58. package/dist/rename.js +21 -5
  59. package/dist/rendered-docs.js +3 -2
  60. package/dist/signature.js +4 -3
  61. package/dist/snippet-session.d.ts +1 -1
  62. package/dist/snippet-session.js +8 -2
  63. package/dist/symbol-picker.js +3 -2
  64. package/package.json +6 -6
  65. package/src/AnchoredPopup.tsx +4 -2
  66. package/src/CodeAuthorsCard.tsx +21 -12
  67. package/src/CompletionPopup.tsx +11 -1
  68. package/src/EditorView.tsx +122 -11
  69. package/src/HoverCard.tsx +6 -1
  70. package/src/LanguagePopups.tsx +23 -4
  71. package/src/PickPopup.tsx +9 -1
  72. package/src/RenameCard.tsx +15 -9
  73. package/src/RenderedDocs.tsx +30 -8
  74. package/src/call-hierarchy.ts +15 -14
  75. package/src/code-actions.ts +5 -2
  76. package/src/completion-model.ts +3 -1
  77. package/src/completion.ts +48 -28
  78. package/src/diagnostics-model.ts +1 -1
  79. package/src/diagnostics.ts +29 -13
  80. package/src/diff-overlays.tsx +12 -8
  81. package/src/editor-language.ts +12 -1
  82. package/src/format-on-type.ts +2 -3
  83. package/src/highlights.ts +3 -2
  84. package/src/history.ts +3 -2
  85. package/src/holder.ts +5 -4
  86. package/src/host-types.ts +5 -0
  87. package/src/hover.ts +19 -5
  88. package/src/index.ts +6 -2
  89. package/src/key-priority.ts +49 -0
  90. package/src/locales/en.json +2 -1
  91. package/src/locales/nl.json +2 -1
  92. package/src/models.ts +1 -1
  93. package/src/navigation.ts +3 -2
  94. package/src/peek.ts +3 -2
  95. package/src/pick.ts +2 -1
  96. package/src/popup-focus.ts +17 -0
  97. package/src/popups.ts +1 -2
  98. package/src/project-language.ts +5 -4
  99. package/src/project-problems.ts +46 -11
  100. package/src/proposal-model.ts +0 -9
  101. package/src/rename-model.ts +60 -1
  102. package/src/rename.ts +21 -5
  103. package/src/rendered-docs.ts +3 -2
  104. package/src/signature.ts +4 -3
  105. package/src/snippet-session.ts +9 -3
  106. package/src/symbol-picker.ts +3 -2
  107. package/dist/relative-time.d.ts +0 -1
  108. package/dist/relative-time.js +0 -9
  109. package/src/relative-time.ts +0 -10
package/dist/rename.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { StaleResultError } from '@adecore/lsp';
2
+ import { KEY_PRIORITY } from './key-priority.js';
2
3
  import { lineTextOf } from './line-text.js';
3
- import { occurrencesOf, renamePreviewOf, renameTargetOf, wordRangeAt } from './rename-model.js';
4
+ import { occurrencesOf, renameKindOf, renamePreviewOf, renameTargetOf, wordRangeAt } from './rename-model.js';
4
5
  import { isShortcut } from './shortcut-keys.js';
5
6
  import { resourceOperationsOf } from './workspace-edit.js';
6
7
  import { messageOf } from './error-message.js';
@@ -22,13 +23,13 @@ export class RenameFeature {
22
23
  this.language = language;
23
24
  const { editor } = language;
24
25
  const offs = [
25
- editor.onKeyDown((event) => {
26
+ language.onKey((event) => {
26
27
  if (!isShortcut(this.language.shortcuts.rename, event)) {
27
28
  return false;
28
29
  }
29
30
  void this.start();
30
31
  return true;
31
- }),
32
+ }, KEY_PRIORITY.rename),
32
33
  editor.onTextChange(() => this.cancel())
33
34
  ];
34
35
  language.onDispose(() => {
@@ -192,8 +193,9 @@ export class RenameFeature {
192
193
  start: { line: Math.max(0, line - CONTEXT_LINES), character: 0 },
193
194
  end: { line: line + CONTEXT_LINES, character: Number.MAX_SAFE_INTEGER }
194
195
  });
195
- const uses = await this.linesOf(locations ?? []);
196
- const names = await suggestNames({ uri, languageId, name: session.original, context, uses }, session.names.signal);
196
+ const [uses, kind] = await Promise.all([this.linesOf(locations ?? []), this.kindOf(session, locations ?? [])]);
197
+ const request = { uri, languageId, name: session.original, ...(kind === undefined ? {} : { kind }), context, uses };
198
+ const names = await suggestNames(request, session.names.signal);
197
199
  if (this.session === session && names.length > 0) {
198
200
  this.publish({ suggestions: names });
199
201
  }
@@ -202,6 +204,20 @@ export class RenameFeature {
202
204
  // Suggestions do not prevent a manual rename.
203
205
  }
204
206
  }
207
+ /* So a suggester knows a function from a variable; a rename on a use finds the kind through the declaration's place in this file. */
208
+ async kindOf(session, locations) {
209
+ const { project, uri } = this.language;
210
+ if (!project.service.supports('textDocument/documentSymbol', uri)) {
211
+ return undefined;
212
+ }
213
+ try {
214
+ const symbols = await project.service.documentSymbols(uri, { signal: session.names.signal, parallel: true });
215
+ return renameKindOf(symbols, session.original, [session.target.range, ...occurrencesOf(locations, uri).inFile]);
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
205
221
  /* The text of the first lines that name the symbol, in the order the servers listed them, reading at most a few other files. */
206
222
  async linesOf(locations) {
207
223
  const { project } = this.language;
@@ -1,4 +1,5 @@
1
1
  import { findDocComments } from '@adecore/editor';
2
+ import { KEY_PRIORITY } from './key-priority.js';
2
3
  import { parseDocComment } from './doc-comment-model.js';
3
4
  import { isShortcut } from './shortcut-keys.js';
4
5
  import { realTimers } from './timers.js';
@@ -86,9 +87,9 @@ export class RenderedDocsFeature {
86
87
  this.toggleAt(line);
87
88
  }
88
89
  }),
89
- editor.onKeyDown((event) => {
90
+ language.onKey((event) => {
90
91
  return isShortcut(this.language.shortcuts.toggleRenderedDocs, event) && this.toggle();
91
- }),
92
+ }, KEY_PRIORITY.renderedDocs),
92
93
  editor.onViewChange(() => {
93
94
  this.sweep();
94
95
  this.releaseRevealed();
package/dist/signature.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { KEY_PRIORITY } from './key-priority.js';
1
2
  import { signatureViewOf } from './signature-model.js';
2
3
  import { isShortcut } from './shortcut-keys.js';
3
4
  import { realTimers } from './timers.js';
@@ -24,8 +25,8 @@ export class SignatureFeature {
24
25
  editor.onTextChange((change) => this.edited(change)),
25
26
  editor.onCaret(() => this.retrigger()),
26
27
  editor.onBlur(() => this.close()),
27
- editor.onKeyDown((event) => {
28
- if (event.key === 'Escape' && this.isOpen && this.language.popups.getState().callHierarchy == null) {
28
+ language.onKey((event) => {
29
+ if (event.key === 'Escape' && this.isOpen && this.language.popups.getState().callHierarchy === null) {
29
30
  this.close();
30
31
  return true;
31
32
  }
@@ -37,7 +38,7 @@ export class SignatureFeature {
37
38
  return this.cycle(event.key === 'ArrowDown' ? 1 : -1);
38
39
  }
39
40
  return false;
40
- })
41
+ }, KEY_PRIORITY.signature)
41
42
  ];
42
43
  language.onDispose(() => {
43
44
  for (const off of offs) {
@@ -10,7 +10,7 @@ export interface SnippetInsertion {
10
10
  readonly stops: readonly SnippetStop[];
11
11
  readonly fields?: readonly SnippetField[];
12
12
  }
13
- type SnippetHost = Pick<EditorLanguage, 'editor' | 'onDispose'> & Partial<Pick<EditorLanguage, 'popups' | 'pick' | 'project'>>;
13
+ type SnippetHost = Pick<EditorLanguage, 'editor' | 'onDispose'> & Partial<Pick<EditorLanguage, 'popups' | 'pick' | 'project' | 'onKey'>>;
14
14
  export declare class SnippetFeature {
15
15
  private readonly language;
16
16
  private frames;
@@ -3,6 +3,7 @@ import { SnippetFieldTransforms } from './snippet-field-transforms.js';
3
3
  import { SnippetTransformError } from './snippet-transform-client.js';
4
4
  import { acquireSnippetTransforms } from './snippet-transform-queue.js';
5
5
  import { realTimers } from './timers.js';
6
+ import { KEY_PRIORITY } from './key-priority.js';
6
7
  import { MAX_TRANSFORM_CHARACTERS } from './snippet-transform-protocol.js';
7
8
  import { comparePositions, rangeHolds, shiftPosition } from './diagnostics-model.js';
8
9
  export function positionInInsertion(start, text, offset) {
@@ -128,7 +129,12 @@ export class SnippetFeature {
128
129
  this.transforms = language.project === undefined ? null : acquireSnippetTransforms(language.project, timers);
129
130
  const { editor } = language;
130
131
  this.fieldTransforms = new SnippetFieldTransforms(editor, this.transforms, (targets, values) => this.applyFieldTransforms(targets, values));
131
- const offs = [editor.onKeyDown((event) => this.key(event)), editor.onTextChange((change) => this.edited(change)), editor.onCaret(() => this.moved())];
132
+ const key = (event) => this.key(event);
133
+ const offs = [
134
+ language.onKey === undefined ? editor.onKeyDown(key) : language.onKey(key, KEY_PRIORITY.snippets),
135
+ editor.onTextChange((change) => this.edited(change)),
136
+ editor.onCaret(() => this.moved())
137
+ ];
132
138
  language.onDispose(() => {
133
139
  this.transforms?.dispose();
134
140
  this.end();
@@ -395,7 +401,7 @@ export class SnippetFeature {
395
401
  if (event.key === 'Escape') {
396
402
  const popups = this.language.popups?.getState();
397
403
  if (popups !== undefined &&
398
- [popups.completion, popups.signature, popups.hover, popups.pick, popups.peek, popups.callHierarchy ?? null].some((popup) => popup !== null)) {
404
+ [popups.completion, popups.signature, popups.hover, popups.pick, popups.peek, popups.callHierarchy].some((popup) => popup !== null)) {
399
405
  return false;
400
406
  }
401
407
  const active = this.isActive;
@@ -1,4 +1,5 @@
1
1
  import { StaleResultError } from '@adecore/lsp';
2
+ import { KEY_PRIORITY } from './key-priority.js';
2
3
  import { basenameOf } from './paths.js';
3
4
  import { entriesOf, workspaceEntriesOf } from './symbol-picker-model.js';
4
5
  import { isShortcut } from './shortcut-keys.js';
@@ -13,13 +14,13 @@ export class SymbolPickerFeature {
13
14
  language;
14
15
  constructor(language) {
15
16
  this.language = language;
16
- const off = language.editor.onKeyDown((event) => {
17
+ const off = language.onKey((event) => {
17
18
  if (!isShortcut(this.language.shortcuts.goToSymbol, event)) {
18
19
  return false;
19
20
  }
20
21
  void this.open();
21
22
  return true;
22
- });
23
+ }, KEY_PRIORITY.symbolPicker);
23
24
  language.onDispose(() => {
24
25
  off();
25
26
  this.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-react",
3
- "version": "0.23.1",
3
+ "version": "0.24.1",
4
4
  "type": "module",
5
5
  "description": "Language-enabled editors, feature coordination and React popups over injected language services.",
6
6
  "license": "FSL-1.1-MIT",
@@ -62,9 +62,9 @@
62
62
  "test": "bun test --conditions=source"
63
63
  },
64
64
  "dependencies": {
65
- "@adecore/editor": "0.23.1",
66
- "@adecore/lsp": "0.23.1",
67
- "@adecore/merge": "0.23.1",
65
+ "@adecore/editor": "0.24.1",
66
+ "@adecore/lsp": "0.24.1",
67
+ "@adecore/merge": "0.24.1",
68
68
  "lucide-react": "^1.47.0",
69
69
  "react-markdown": "^10.1.0",
70
70
  "remark-breaks": "^4.0.0",
@@ -72,14 +72,14 @@
72
72
  "zustand": "^5.0.15"
73
73
  },
74
74
  "peerDependencies": {
75
- "@adecore/ui": "^0.23.1",
75
+ "@adecore/ui": "^0.24.1",
76
76
  "i18next": "^26.4.2",
77
77
  "react": "^19.3.0",
78
78
  "react-dom": "^19.3.0",
79
79
  "react-i18next": "^17.0.15"
80
80
  },
81
81
  "devDependencies": {
82
- "@adecore/ui": "workspace:*",
82
+ "@adecore/ui": "0.24.1",
83
83
  "@types/bun": "^1.4.2",
84
84
  "@types/react": "^19.3.0",
85
85
  "@types/react-dom": "^19.3.0",
@@ -7,6 +7,8 @@ export interface AnchoredPopupProps {
7
7
  /* The character the popup belongs to, in the page's pixels. */
8
8
  rect: EditorRect;
9
9
  className?: string;
10
+ /* What the card does with content past its room: `scroll` it, or `clip` it for content that scrolls on its own. `scroll` without one. */
11
+ overflow?: 'scroll' | 'clip';
10
12
  children: ReactNode;
11
13
  placement?: PlaceOptions;
12
14
  onPointerEnter?(): void;
@@ -17,7 +19,7 @@ export interface AnchoredPopupProps {
17
19
  * A card next to a character of the editor, portaled to the page: inside a scaled canvas node it would be
18
20
  * drawn at that scale and clipped by the node. Placed from the character's screen rectangle, so it holds at any zoom.
19
21
  */
20
- export function AnchoredPopup({ rect, className, children, placement, onPointerEnter, onPointerLeave }: AnchoredPopupProps) {
22
+ export function AnchoredPopup({ rect, className, overflow = 'scroll', children, placement, onPointerEnter, onPointerLeave }: AnchoredPopupProps) {
21
23
  const element = useRef<HTMLDivElement>(null);
22
24
 
23
25
  // Every render, since the character moves when the editor scrolls and the card's own size follows its content.
@@ -38,7 +40,7 @@ export function AnchoredPopup({ rect, className, children, placement, onPointerE
38
40
  return createPortal(
39
41
  <div
40
42
  ref={element}
41
- className={`fixed top-0 left-0 z-(--z-popup) overflow-y-auto rounded-lg border border-border bg-surface-raised text-text shadow-(--float-shadow) ${className ?? ''}`}
43
+ className={`fixed top-0 left-0 z-(--z-popup) ${overflow === 'clip' ? 'overflow-hidden' : 'overflow-y-auto'} rounded-lg border border-border bg-surface-raised text-text shadow-(--float-shadow) ${className ?? ''}`}
42
44
  style={{ visibility: 'hidden' }}
43
45
  onPointerEnter={onPointerEnter}
44
46
  onPointerLeave={onPointerLeave}
@@ -2,20 +2,37 @@ import { useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { GitCommitHorizontal, User, Users } from 'lucide-react';
4
4
  import { Button, Icon, Popover, Separator, Tooltip, useNow } from '@adecore/ui';
5
- import { formatNumber } from '@adecore/ui/format';
6
- import { relativeTime } from './relative-time.ts';
5
+ import { formatAge, formatNumber } from '@adecore/ui/format';
7
6
  import type { EditorLanguage } from './editor-language.ts';
7
+ import type { GitBlameCommit } from './host-types.ts';
8
8
  import type { AuthorsView } from './popups.ts';
9
9
 
10
10
  const MINUTE_MS = 60_000;
11
11
 
12
+ /* The newest commit that touched the declaration. Its clock ticks here, so a minute redraws this block and not the card. */
13
+ export function LatestCommit({ commit }: { commit: GitBlameCommit }) {
14
+ const { t } = useTranslation('editor');
15
+ const now = useNow(MINUTE_MS);
16
+
17
+ return (
18
+ <div className="flex flex-col gap-1">
19
+ <span className="text-text-muted">{t('language.codeVision.latest')}</span>
20
+ <span className="text-text select-text">{commit.summary}</span>
21
+ <span className="flex items-center gap-1.5 text-text-muted">
22
+ <span className="font-mono select-text">{commit.shortHash}</span>
23
+ <span className="min-w-0 truncate">{commit.author}</span>
24
+ <span className="shrink-0">{formatAge(commit.at * 1000, now)}</span>
25
+ </span>
26
+ </div>
27
+ );
28
+ }
29
+
12
30
  /*
13
31
  * Who wrote a declaration, by lines, and the newest commit that touched it. It opens beside the entry that
14
32
  * was pressed and closes on Escape or a press anywhere else; Escape hands the keyboard back to the editor.
15
33
  */
16
34
  export function CodeAuthorsCard({ language, view }: { language: EditorLanguage; view: AuthorsView }) {
17
35
  const { t } = useTranslation('editor');
18
- const now = Math.floor(useNow(MINUTE_MS) / 1000);
19
36
  const { authors, uncommittedLines, latest } = view.authorship;
20
37
  const anchor = useMemo(() => {
21
38
  const { left, top, right, bottom } = view.anchor;
@@ -52,15 +69,7 @@ export function CodeAuthorsCard({ language, view }: { language: EditorLanguage;
52
69
  {latest !== null && (
53
70
  <>
54
71
  <Separator />
55
- <div className="flex flex-col gap-1">
56
- <span className="text-text-muted">{t('language.codeVision.latest')}</span>
57
- <span className="text-text select-text">{latest.summary}</span>
58
- <span className="flex items-center gap-1.5 text-text-muted">
59
- <span className="font-mono select-text">{latest.shortHash}</span>
60
- <span className="min-w-0 truncate">{latest.author}</span>
61
- <span className="shrink-0">{relativeTime(latest.at, now)}</span>
62
- </span>
63
- </div>
72
+ <LatestCommit commit={latest} />
64
73
  {view.openCommit !== null && (
65
74
  <div className="flex justify-end">
66
75
  <Popover.Close
@@ -1,11 +1,13 @@
1
1
  import { useId, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { formatShortcut } from '@adecore/ui';
4
5
  import type { EditorRect } from '@adecore/editor';
5
6
  import type { EditorLanguage } from './editor-language.ts';
6
7
  import { kindLetterOf, kindToneOf, rowWindow, type KindTone } from './completion-model.ts';
7
8
  import { SourceLine, SymbolSections } from './HoverSections.tsx';
8
9
  import { PathText } from './PathText.tsx';
10
+ import { keepFocusOutOfList } from './popup-focus.ts';
9
11
  import { placeBeside, placePopup } from './popup-placement.ts';
10
12
  import type { CompletionView } from './popups.ts';
11
13
 
@@ -64,6 +66,8 @@ const ROW_CHROME = 72;
64
66
  */
65
67
  export function CompletionPopup({ language, view, rect }: { language: EditorLanguage; view: CompletionView; rect: EditorRect }) {
66
68
  const { t } = useTranslation('editor');
69
+ const toggle = language.shortcuts.triggerCompletion;
70
+ const detailsKey = toggle === null ? null : formatShortcut(toggle, toggle.apple);
67
71
  const listId = useId();
68
72
  const list = useRef<HTMLDivElement>(null);
69
73
  const docs = useRef<HTMLDivElement>(null);
@@ -131,6 +135,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
131
135
  className={`${CARD} flex w-max max-w-[min(560px,calc(100vw-16px))] min-w-[340px] flex-col`}
132
136
  style={{ visibility: 'hidden' }}
133
137
  onPointerDown={(event) => event.preventDefault()}
138
+ {...keepFocusOutOfList(() => language.editor.focus())}
134
139
  >
135
140
  {/* Sizes the list to the widest rows in the face the rows use; the rows themselves fill that width and truncate inside it. */}
136
141
  <div
@@ -157,6 +162,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
157
162
  ref={index === view.active ? activeRow : undefined}
158
163
  type="button"
159
164
  role="option"
165
+ tabIndex={-1}
160
166
  aria-selected={index === view.active}
161
167
  aria-setsize={total}
162
168
  aria-posinset={index + 1}
@@ -182,7 +188,11 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
182
188
  <div className="flex items-center gap-3 border-t border-border px-2 py-1 text-xs whitespace-nowrap text-text-faint">
183
189
  <span>↵ {t('language.completion.insert')}</span>
184
190
  <span>⇥ {t('language.completion.replace')}</span>
185
- <span>^Space {t('language.completion.details')}</span>
191
+ {detailsKey !== null && (
192
+ <span>
193
+ {detailsKey} {t('language.completion.details')}
194
+ </span>
195
+ )}
186
196
  <span className="ml-auto">{view.server}</span>
187
197
  </div>
188
198
  </div>
@@ -2,47 +2,146 @@ import { createHolder } from './holder.ts';
2
2
  import { useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode, type Ref } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { TFunction } from 'i18next';
5
- import type { Editor, EditorEngine, EditorMessages, EditorOptions } from '@adecore/editor';
6
- import { EditorLanguage } from './editor-language.ts';
5
+ import {
6
+ DEFAULT_SMART_KEYS,
7
+ type Editor,
8
+ type EditorEngine,
9
+ type EditorIndentation,
10
+ type EditorMessages,
11
+ type EditorOptions,
12
+ type EditorSmartKeys
13
+ } from '@adecore/editor';
14
+ import { EditorLanguage, type EditorLanguageOptions } from './editor-language.ts';
7
15
  import { LanguagePopups } from './LanguagePopups.tsx';
8
16
  import type { ProjectLanguage } from './project-language.ts';
17
+ import type { HoverView } from './popups.ts';
18
+ import type { Timers } from './timers.ts';
9
19
 
10
20
  export interface EditorViewProps {
11
21
  engine: EditorEngine;
22
+ /* Read when the editor mounts; a later change of a setting the editor has a setter for reaches it in place. */
12
23
  options: EditorOptions;
13
24
  project?: ProjectLanguage;
14
25
  uri?: string;
15
26
  languageId?: string;
27
+ /* The clock of the language features, read when the editor mounts. */
28
+ timers?: Timers;
29
+ /* Read when the editor mounts. */
30
+ languageOptions?: EditorLanguageOptions;
16
31
  className?: string;
17
32
  ref?: Ref<HTMLDivElement>;
33
+ /* Read when the editor mounts, so an inline function does not mount it again. */
18
34
  onMount?(editor: Editor, language: EditorLanguage | null): void | (() => void);
19
35
  /* Rows of the app in the context menu, between the refactorings and the clipboard rows. */
20
36
  contextMenuItems?(language: EditorLanguage): ReactNode;
37
+ /* Sections of the app at the foot of the hover card, such as actions on its problems. */
38
+ hoverFooter?(view: HoverView, language: EditorLanguage): ReactNode;
21
39
  }
22
40
 
41
+ interface MountInputs {
42
+ readonly options: EditorOptions;
43
+ readonly timers: Timers | undefined;
44
+ readonly languageOptions: EditorLanguageOptions | undefined;
45
+ readonly onMount: EditorViewProps['onMount'];
46
+ }
47
+
48
+ const DEFAULT_INDENTATION: EditorIndentation = { tabSize: 4, insertSpaces: true };
49
+
23
50
  /* The names of the fold controls in the editor's `{start}` and `{end}` placeholders, which the editor fills in per control. */
24
51
  function foldMessages(t: TFunction<'editor'>): Pick<EditorMessages, 'fold' | 'unfold'> {
25
52
  const lines = { start: '{start}', end: '{end}' };
26
53
  return { fold: t('language.fold.fold', lines), unfold: t('language.fold.unfold', lines) };
27
54
  }
28
55
 
29
- export function EditorView({ engine, options, project, uri, languageId, className, ref, onMount, contextMenuItems }: EditorViewProps) {
56
+ function sameFields<T extends object>(left: T | undefined, right: T | undefined): boolean {
57
+ if (left === right) {
58
+ return true;
59
+ }
60
+ if (left === undefined || right === undefined) {
61
+ return false;
62
+ }
63
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)] as (keyof T)[]);
64
+ return [...keys].every((key) => left[key] === right[key]);
65
+ }
66
+
67
+ /* Hands the editor the settings that changed by value, so a new options object with the same values costs nothing. */
68
+ function applyChangedOptions(editor: Editor, before: EditorOptions, after: EditorOptions): void {
69
+ if (before.theme !== after.theme) {
70
+ editor.setTheme(after.theme);
71
+ }
72
+ if (before.readOnly !== after.readOnly || before.readOnlyReason !== after.readOnlyReason) {
73
+ editor.setReadOnly(after.readOnly ?? false, after.readOnlyReason);
74
+ }
75
+ if (before.wrap !== after.wrap) {
76
+ editor.setWrap(after.wrap ?? false);
77
+ }
78
+ if (!sameFields(before.indentation, after.indentation)) {
79
+ editor.setIndentation(after.indentation ?? DEFAULT_INDENTATION);
80
+ }
81
+ if (!sameFields<Partial<EditorSmartKeys>>(before.smartKeys, after.smartKeys)) {
82
+ // The setter keeps the keys left out, where the options mean the default.
83
+ editor.setSmartKeys({ ...DEFAULT_SMART_KEYS, ...after.smartKeys });
84
+ }
85
+ if (before.guides !== after.guides) {
86
+ editor.setGuides(after.guides ?? true);
87
+ }
88
+ if (before.stickyScroll !== after.stickyScroll) {
89
+ editor.setStickyScroll?.(after.stickyScroll ?? true);
90
+ }
91
+ if (before.whitespace !== after.whitespace) {
92
+ editor.setWhitespace(after.whitespace ?? false);
93
+ }
94
+ if (before.rightMargin !== after.rightMargin) {
95
+ editor.setRightMargin(after.rightMargin ?? null);
96
+ }
97
+ if (before.foldOutline !== after.foldOutline) {
98
+ editor.setFoldOutline(after.foldOutline ?? 'hover');
99
+ }
100
+ if (after.label !== undefined && before.label !== after.label) {
101
+ editor.setLabel(after.label);
102
+ }
103
+ }
104
+
105
+ export function EditorView({
106
+ engine,
107
+ options,
108
+ project,
109
+ uri,
110
+ languageId,
111
+ timers,
112
+ languageOptions,
113
+ className,
114
+ ref,
115
+ onMount,
116
+ contextMenuItems,
117
+ hoverFooter
118
+ }: EditorViewProps) {
30
119
  const { t } = useTranslation('editor');
31
120
  const element = useRef<HTMLDivElement>(null);
32
121
  const mounted = useRef<Editor | null>(null);
33
122
  const words = useRef(t);
123
+ const latest = useRef<MountInputs>({ options, timers, languageOptions, onMount });
124
+ const applied = useRef(options);
34
125
  const holder = useMemo(() => createHolder<EditorLanguage>(), []);
35
126
  const language = useSyncExternalStore(holder.subscribe, holder.get, holder.get);
127
+ useLayoutEffect(() => {
128
+ latest.current = { options, timers, languageOptions, onMount };
129
+ });
36
130
  useLayoutEffect(() => {
37
131
  const host = element.current;
38
132
  if (host === null) {
39
133
  return;
40
134
  }
41
- const editor = engine.mount(host, { ...options, messages: { ...foldMessages(words.current), ...options.messages } });
135
+ const inputs = latest.current;
136
+ const editor = engine.mount(host, { ...inputs.options, messages: { ...foldMessages(words.current), ...inputs.options.messages } });
42
137
  mounted.current = editor;
43
- const attached = project === undefined || uri === undefined || languageId === undefined ? null : new EditorLanguage(project, editor, uri, languageId);
138
+ applied.current = inputs.options;
139
+ const attached =
140
+ project === undefined || uri === undefined || languageId === undefined
141
+ ? null
142
+ : new EditorLanguage(project, editor, uri, languageId, inputs.timers, inputs.languageOptions);
44
143
  holder.set(attached);
45
- const stop = onMount?.(editor, attached);
144
+ const stop = inputs.onMount?.(editor, attached);
46
145
  return () => {
47
146
  mounted.current = null;
48
147
  holder.set(null);
@@ -50,17 +149,29 @@ export function EditorView({ engine, options, project, uri, languageId, classNam
50
149
  attached?.dispose();
51
150
  editor.dispose();
52
151
  };
53
- }, [engine, options, project, uri, languageId, holder, onMount]);
152
+ }, [engine, project, uri, languageId, holder]);
153
+ useLayoutEffect(() => {
154
+ const editor = mounted.current;
155
+ if (editor === null || applied.current === options) {
156
+ return;
157
+ }
158
+ applyChangedOptions(editor, applied.current, options);
159
+ applied.current = options;
160
+ });
161
+ const { fold, unfold, noMoreOccurrences } = options.messages ?? {};
54
162
  useLayoutEffect(() => {
55
163
  words.current = t;
56
- const { fold, unfold } = options.messages ?? {};
57
164
  const localized = foldMessages(t);
58
- mounted.current?.setMessages({ fold: fold ?? localized.fold, unfold: unfold ?? localized.unfold });
59
- }, [t, options.messages]);
165
+ mounted.current?.setMessages({
166
+ fold: fold ?? localized.fold,
167
+ unfold: unfold ?? localized.unfold,
168
+ ...(noMoreOccurrences === undefined ? {} : { noMoreOccurrences })
169
+ });
170
+ }, [t, fold, unfold, noMoreOccurrences]);
60
171
  return (
61
172
  <div className={className} ref={ref} style={{ position: 'relative', minHeight: 0 }}>
62
173
  <div ref={element} style={{ position: 'absolute', inset: 0 }} />
63
- {language !== null && <LanguagePopups language={language} contextMenuItems={contextMenuItems} />}
174
+ {language !== null && <LanguagePopups language={language} contextMenuItems={contextMenuItems} hoverFooter={hoverFooter} />}
64
175
  </div>
65
176
  );
66
177
  }
package/src/HoverCard.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react';
1
2
  import { useTranslation } from 'react-i18next';
2
3
  import { CircleX, Info, TriangleAlert } from 'lucide-react';
3
4
  import { fileUriToPath, type Location } from '@adecore/lsp';
@@ -88,13 +89,16 @@ export function HoverCard({
88
89
  problems,
89
90
  info,
90
91
  anchor,
91
- position
92
+ position,
93
+ footer
92
94
  }: {
93
95
  language: EditorLanguage;
94
96
  problems: readonly Problem[];
95
97
  info: HoverInfo | null;
96
98
  anchor: EditorPosition;
97
99
  position: EditorPosition;
100
+ /* Sections of the app below the problems, each with a divider above it. */
101
+ footer?: ReactNode;
98
102
  }) {
99
103
  const { t } = useTranslation('editor');
100
104
  return (
@@ -137,6 +141,7 @@ export function HoverCard({
137
141
  {problems.map((problem, index) => (
138
142
  <ProblemSection key={index} problem={problem} language={language} />
139
143
  ))}
144
+ {footer}
140
145
  </div>
141
146
  );
142
147
  }
@@ -13,13 +13,24 @@ import { RenameCard } from './RenameCard.tsx';
13
13
  import { RenderedDocs } from './RenderedDocs.tsx';
14
14
  import { SymbolPicker } from './SymbolPicker.tsx';
15
15
  import { SignatureCard } from './SignatureCard.tsx';
16
+ import type { HoverView } from './popups.ts';
17
+
18
+ export interface LanguagePopupsProps<Language extends EditorLanguage = EditorLanguage> {
19
+ language: Language;
20
+ /* Rows of the app in the context menu, between the refactorings and the clipboard rows. */
21
+ contextMenuItems?(language: Language): ReactNode;
22
+ /* Sections of the app at the foot of the hover card, such as actions on its problems; each element gets a divider above it. */
23
+ hoverFooter?(view: HoverView, language: Language): ReactNode;
24
+ /* A layer of the app over the editor, drawn above the cards and below the rename field. */
25
+ children?: ReactNode;
26
+ }
16
27
 
17
28
  /*
18
29
  * Every card a language feature opens over the editor. Each feature keeps its own state and these
19
30
  * only draw it, placed by the screen position of the character it belongs to, which is read again
20
- * whenever the editor scrolls or resizes. `contextMenuItems` adds rows of the app to the context menu.
31
+ * whenever the editor scrolls or resizes.
21
32
  */
22
- export function LanguagePopups({ language, contextMenuItems }: { language: EditorLanguage; contextMenuItems?: (language: EditorLanguage) => ReactNode }) {
33
+ export function LanguagePopups<Language extends EditorLanguage>({ language, contextMenuItems, hoverFooter, children }: LanguagePopupsProps<Language>) {
23
34
  const [, redraw] = useReducer((count: number) => count + 1, 0);
24
35
  const hover = useStore(language.popups, (state) => state.hover);
25
36
  const completion = useStore(language.popups, (state) => state.completion);
@@ -27,7 +38,7 @@ export function LanguagePopups({ language, contextMenuItems }: { language: Edito
27
38
  const pick = useStore(language.popups, (state) => state.pick);
28
39
  const rename = useStore(language.popups, (state) => state.rename);
29
40
  const peek = useStore(language.popups, (state) => state.peek);
30
- const callHierarchy = useStore(language.popups, (state) => state.callHierarchy ?? null);
41
+ const callHierarchy = useStore(language.popups, (state) => state.callHierarchy);
31
42
  const symbols = useStore(language.popups, (state) => state.symbols);
32
43
  const menu = useStore(language.popups, (state) => state.menu);
33
44
  const authors = useStore(language.popups, (state) => state.authors);
@@ -45,7 +56,14 @@ export function LanguagePopups({ language, contextMenuItems }: { language: Edito
45
56
  <RenderedDocs language={language} />
46
57
  {hover !== null && hoverRect !== null && (
47
58
  <AnchoredPopup rect={hoverRect} onPointerEnter={() => language.hover.holdCard(true)} onPointerLeave={() => language.hover.holdCard(false)}>
48
- <HoverCard language={language} problems={hover.problems} info={hover.info} anchor={hover.anchor} position={hover.position} />
59
+ <HoverCard
60
+ language={language}
61
+ problems={hover.problems}
62
+ info={hover.info}
63
+ anchor={hover.anchor}
64
+ position={hover.position}
65
+ footer={hoverFooter?.(hover, language)}
66
+ />
49
67
  </AnchoredPopup>
50
68
  )}
51
69
  {signature !== null && signatureRect !== null && (
@@ -64,6 +82,7 @@ export function LanguagePopups({ language, contextMenuItems }: { language: Edito
64
82
  {peek !== null && <PeekPanel language={language} view={peek} />}
65
83
  {callHierarchy !== null && <CallHierarchyPanel language={language} view={callHierarchy} />}
66
84
  {authors !== null && <CodeAuthorsCard language={language} view={authors} />}
85
+ {children}
67
86
  {rename !== null && renameRect !== null && <RenameCard language={language} view={rename} rect={renameRect} endRect={renameEnd} />}
68
87
  </>
69
88
  );
package/src/PickPopup.tsx CHANGED
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
3
3
  import type { EditorRect } from '@adecore/editor';
4
4
  import type { EditorLanguage } from './editor-language.ts';
5
5
  import { PathText } from './PathText.tsx';
6
+ import { keepFocusOutOfList } from './popup-focus.ts';
6
7
  import { placePopup } from './popup-placement.ts';
7
8
  import type { PickPreview, PickView } from './popups.ts';
8
9
 
@@ -80,7 +81,13 @@ export function PickPopup({ language, view, rect }: { language: EditorLanguage;
80
81
  }, [language, listId, activeId]);
81
82
 
82
83
  return createPortal(
83
- <div ref={card} className={CARD} style={{ visibility: 'hidden' }} onPointerDown={(event) => event.preventDefault()}>
84
+ <div
85
+ ref={card}
86
+ className={CARD}
87
+ style={{ visibility: 'hidden' }}
88
+ onPointerDown={(event) => event.preventDefault()}
89
+ {...keepFocusOutOfList(() => language.editor.focus())}
90
+ >
84
91
  {view.title !== null && <div className="border-b border-border px-3 py-1.5 text-xs text-text-muted">{view.title}</div>}
85
92
  <div id={listId} role="listbox" className="min-h-0 flex-1 overflow-y-auto p-1">
86
93
  {view.groups.map((group, groupIndex) => (
@@ -93,6 +100,7 @@ export function PickPopup({ language, view, rect }: { language: EditorLanguage;
93
100
  ref={row.id === view.active ? activeRow : undefined}
94
101
  type="button"
95
102
  role="option"
103
+ tabIndex={-1}
96
104
  aria-selected={row.id === view.active}
97
105
  data-active={row.id === view.active}
98
106
  className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-xs cursor-row"