@adecore/editor-react 0.22.0 → 0.23.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 (196) hide show
  1. package/dist/CallHierarchyPanel.d.ts +6 -0
  2. package/dist/CallHierarchyPanel.js +88 -0
  3. package/dist/CompletionPopup.js +11 -3
  4. package/dist/DiffView.d.ts +22 -0
  5. package/dist/DiffView.js +203 -0
  6. package/dist/EditorContextMenu.js +1 -0
  7. package/dist/EditorView.js +18 -1
  8. package/dist/FindReplace.d.ts +1 -1
  9. package/dist/FindReplace.js +41 -11
  10. package/dist/HoverCard.js +2 -2
  11. package/dist/LanguagePopups.js +4 -1
  12. package/dist/Markdown.d.ts +2 -1
  13. package/dist/Markdown.js +8 -4
  14. package/dist/PeekPanel.d.ts +3 -0
  15. package/dist/PeekPanel.js +2 -3
  16. package/dist/PickPopup.js +15 -2
  17. package/dist/RenameCard.js +10 -5
  18. package/dist/RenderedDocs.d.ts +9 -0
  19. package/dist/RenderedDocs.js +84 -0
  20. package/dist/SplitDiffView.d.ts +9 -0
  21. package/dist/SplitDiffView.js +276 -0
  22. package/dist/SymbolPicker.d.ts +3 -0
  23. package/dist/SymbolPicker.js +4 -3
  24. package/dist/call-hierarchy-model.d.ts +52 -0
  25. package/dist/call-hierarchy-model.js +99 -0
  26. package/dist/call-hierarchy.d.ts +42 -0
  27. package/dist/call-hierarchy.js +399 -0
  28. package/dist/code-actions-model.d.ts +2 -0
  29. package/dist/code-actions-model.js +12 -10
  30. package/dist/code-actions.js +3 -1
  31. package/dist/completion-model.d.ts +14 -4
  32. package/dist/completion-model.js +40 -33
  33. package/dist/completion.d.ts +4 -0
  34. package/dist/completion.js +257 -42
  35. package/dist/diagnostics.js +8 -6
  36. package/dist/diff-expansion.d.ts +9 -0
  37. package/dist/diff-expansion.js +41 -0
  38. package/dist/diff-overlays.d.ts +15 -0
  39. package/dist/diff-overlays.js +28 -0
  40. package/dist/diff-syntax.d.ts +16 -0
  41. package/dist/diff-syntax.js +49 -0
  42. package/dist/diff-wordmarks.d.ts +5 -0
  43. package/dist/diff-wordmarks.js +26 -0
  44. package/dist/doc-comment-model.d.ts +23 -0
  45. package/dist/doc-comment-model.js +291 -0
  46. package/dist/editor-language.d.ts +13 -1
  47. package/dist/editor-language.js +14 -4
  48. package/dist/editor-react.css +79 -0
  49. package/dist/format-on-type.d.ts +14 -0
  50. package/dist/format-on-type.js +96 -0
  51. package/dist/host-types.d.ts +17 -0
  52. package/dist/hover.d.ts +4 -0
  53. package/dist/hover.js +15 -2
  54. package/dist/index.d.ts +19 -5
  55. package/dist/index.js +9 -1
  56. package/dist/inlay-hints.d.ts +47 -2
  57. package/dist/inlay-hints.js +353 -16
  58. package/dist/language-ids.js +4 -2
  59. package/dist/locales/en.json +114 -3
  60. package/dist/locales/nl.json +114 -3
  61. package/dist/models.d.ts +6 -2
  62. package/dist/models.js +5 -1
  63. package/dist/patch-decorations.d.ts +27 -0
  64. package/dist/patch-decorations.js +182 -0
  65. package/dist/patch-layout.d.ts +40 -0
  66. package/dist/patch-layout.js +304 -0
  67. package/dist/peek.js +1 -0
  68. package/dist/pick.d.ts +1 -0
  69. package/dist/pick.js +11 -1
  70. package/dist/popups.d.ts +17 -0
  71. package/dist/popups.js +1 -0
  72. package/dist/project-language.d.ts +8 -0
  73. package/dist/project-language.js +34 -2
  74. package/dist/refresher.d.ts +1 -0
  75. package/dist/refresher.js +5 -0
  76. package/dist/rename-model.d.ts +1 -1
  77. package/dist/rename-model.js +3 -7
  78. package/dist/rename.js +5 -8
  79. package/dist/rendered-docs.d.ts +62 -0
  80. package/dist/rendered-docs.js +368 -0
  81. package/dist/row-host.d.ts +2 -0
  82. package/dist/row-host.js +11 -3
  83. package/dist/semantic-delta.d.ts +2 -0
  84. package/dist/semantic-delta.js +27 -0
  85. package/dist/semantic-model.js +16 -0
  86. package/dist/semantic-tokens.d.ts +26 -1
  87. package/dist/semantic-tokens.js +174 -14
  88. package/dist/shortcut-keys.d.ts +2 -0
  89. package/dist/shortcut-keys.js +6 -2
  90. package/dist/signature.js +1 -1
  91. package/dist/snippet-field-transforms.d.ts +30 -0
  92. package/dist/snippet-field-transforms.js +116 -0
  93. package/dist/snippet-indent.d.ts +2 -0
  94. package/dist/snippet-indent.js +49 -0
  95. package/dist/snippet-range-order.d.ts +3 -0
  96. package/dist/snippet-range-order.js +19 -0
  97. package/dist/snippet-session.d.ts +42 -6
  98. package/dist/snippet-session.js +650 -53
  99. package/dist/snippet-transform-client.d.ts +19 -0
  100. package/dist/snippet-transform-client.js +66 -0
  101. package/dist/snippet-transform-format.d.ts +15 -0
  102. package/dist/snippet-transform-format.js +302 -0
  103. package/dist/snippet-transform-protocol.d.ts +45 -0
  104. package/dist/snippet-transform-protocol.js +8 -0
  105. package/dist/snippet-transform-queue.d.ts +32 -0
  106. package/dist/snippet-transform-queue.js +173 -0
  107. package/dist/snippet-transform-worker.d.ts +10 -0
  108. package/dist/snippet-transform-worker.js +41 -0
  109. package/dist/snippet-variable-names.d.ts +1 -0
  110. package/dist/snippet-variable-names.js +36 -0
  111. package/dist/snippet-variables.d.ts +7 -0
  112. package/dist/snippet-variables.js +66 -0
  113. package/dist/snippet.d.ts +30 -3
  114. package/dist/snippet.js +402 -87
  115. package/dist/split-alignment.d.ts +6 -0
  116. package/dist/split-alignment.js +38 -0
  117. package/dist/split-scroll.d.ts +14 -0
  118. package/dist/split-scroll.js +45 -0
  119. package/dist/symbols.d.ts +2 -1
  120. package/dist/symbols.js +39 -7
  121. package/dist/testing.d.ts +5 -1
  122. package/dist/testing.js +12 -0
  123. package/dist/workspace-edit.d.ts +25 -3
  124. package/dist/workspace-edit.js +282 -143
  125. package/package.json +10 -5
  126. package/src/CallHierarchyPanel.tsx +238 -0
  127. package/src/CompletionPopup.tsx +12 -1
  128. package/src/DiffView.tsx +262 -0
  129. package/src/EditorContextMenu.tsx +1 -0
  130. package/src/EditorView.tsx +21 -2
  131. package/src/FindReplace.tsx +51 -13
  132. package/src/HoverCard.tsx +23 -21
  133. package/src/LanguagePopups.tsx +5 -0
  134. package/src/Markdown.tsx +10 -3
  135. package/src/PeekPanel.tsx +2 -3
  136. package/src/PickPopup.tsx +18 -2
  137. package/src/RenameCard.tsx +18 -0
  138. package/src/RenderedDocs.tsx +155 -0
  139. package/src/SplitDiffView.tsx +337 -0
  140. package/src/SymbolPicker.tsx +4 -3
  141. package/src/call-hierarchy-model.ts +158 -0
  142. package/src/call-hierarchy.ts +441 -0
  143. package/src/code-actions-model.ts +17 -11
  144. package/src/code-actions.ts +3 -1
  145. package/src/completion-model.ts +61 -35
  146. package/src/completion.ts +302 -42
  147. package/src/diagnostics.ts +8 -6
  148. package/src/diff-expansion.ts +49 -0
  149. package/src/diff-overlays.tsx +69 -0
  150. package/src/diff-syntax.ts +67 -0
  151. package/src/diff-wordmarks.ts +31 -0
  152. package/src/doc-comment-model.ts +336 -0
  153. package/src/editor-language.ts +21 -5
  154. package/src/editor-react.css +79 -0
  155. package/src/format-on-type.ts +111 -0
  156. package/src/host-types.ts +19 -0
  157. package/src/hover.ts +18 -3
  158. package/src/index.ts +27 -4
  159. package/src/inlay-hints.ts +394 -18
  160. package/src/language-ids.ts +4 -2
  161. package/src/locales/en.json +114 -3
  162. package/src/locales/nl.json +114 -3
  163. package/src/models.ts +40 -1
  164. package/src/patch-decorations.ts +224 -0
  165. package/src/patch-layout.ts +388 -0
  166. package/src/peek.ts +1 -0
  167. package/src/pick.ts +12 -1
  168. package/src/popups.ts +24 -0
  169. package/src/project-language.ts +43 -2
  170. package/src/refresher.ts +6 -0
  171. package/src/rename-model.ts +4 -8
  172. package/src/rename.ts +5 -8
  173. package/src/rendered-docs.ts +422 -0
  174. package/src/row-host.ts +12 -3
  175. package/src/semantic-delta.ts +29 -0
  176. package/src/semantic-model.ts +17 -0
  177. package/src/semantic-tokens.ts +202 -19
  178. package/src/shortcut-keys.ts +6 -2
  179. package/src/signature.ts +1 -1
  180. package/src/snippet-field-transforms.ts +144 -0
  181. package/src/snippet-indent.ts +53 -0
  182. package/src/snippet-range-order.ts +19 -0
  183. package/src/snippet-session.ts +797 -56
  184. package/src/snippet-transform-client.ts +84 -0
  185. package/src/snippet-transform-format.ts +325 -0
  186. package/src/snippet-transform-protocol.ts +24 -0
  187. package/src/snippet-transform-queue.ts +199 -0
  188. package/src/snippet-transform-worker.ts +58 -0
  189. package/src/snippet-variable-names.ts +36 -0
  190. package/src/snippet-variables.ts +74 -0
  191. package/src/snippet.ts +452 -89
  192. package/src/split-alignment.ts +45 -0
  193. package/src/split-scroll.ts +54 -0
  194. package/src/symbols.ts +43 -7
  195. package/src/testing.ts +25 -0
  196. package/src/workspace-edit.ts +347 -169
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-react",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
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",
@@ -49,6 +49,11 @@
49
49
  "./locales/*.json": {
50
50
  "source": "./src/locales/*.json",
51
51
  "default": "./dist/locales/*.json"
52
+ },
53
+ "./snippet-transform-worker": {
54
+ "source": "./src/snippet-transform-worker.ts",
55
+ "types": "./dist/snippet-transform-worker.d.ts",
56
+ "default": "./dist/snippet-transform-worker.js"
52
57
  }
53
58
  },
54
59
  "scripts": {
@@ -57,9 +62,9 @@
57
62
  "test": "bun test --conditions=source"
58
63
  },
59
64
  "dependencies": {
60
- "@adecore/editor": "0.22.0",
61
- "@adecore/lsp": "0.22.0",
62
- "@adecore/merge": "0.22.0",
65
+ "@adecore/editor": "0.23.0",
66
+ "@adecore/lsp": "0.23.0",
67
+ "@adecore/merge": "0.23.0",
63
68
  "lucide-react": "^1.47.0",
64
69
  "react-markdown": "^10.1.0",
65
70
  "remark-breaks": "^4.0.0",
@@ -67,7 +72,7 @@
67
72
  "zustand": "^5.0.15"
68
73
  },
69
74
  "peerDependencies": {
70
- "@adecore/ui": "^0.22.0",
75
+ "@adecore/ui": "^0.23.0",
71
76
  "i18next": "^26.4.2",
72
77
  "react": "^19.3.0",
73
78
  "react-dom": "^19.3.0",
@@ -0,0 +1,238 @@
1
+ import { useEffect, useId, useLayoutEffect, useRef, type ReactNode, type Ref } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
5
+ import { fileUriToPath } from '@adecore/lsp';
6
+ import { Icon, IconButton, Segmented, Spinner, Tooltip } from '@adecore/ui';
7
+ import { formatNumber } from '@adecore/ui/format';
8
+ import { CALL_HIERARCHY_HEIGHT } from './call-hierarchy.ts';
9
+ import { CALL_LEVEL_LIMIT, type CallDirection, type CallRow } from './call-hierarchy-model.ts';
10
+ import type { EditorLanguage } from './editor-language.ts';
11
+ import { basenameOf, relativeTo } from './paths.ts';
12
+ import { PathText } from './PathText.tsx';
13
+ import { Preview } from './PeekPanel.tsx';
14
+ import type { CallHierarchyView } from './popups.ts';
15
+ import { SymbolBadge } from './SymbolPicker.tsx';
16
+
17
+ const INDENT = 14;
18
+ const BASE_INDENT = 8;
19
+
20
+ type NodeRow = Extract<CallRow, { kind: 'node' }>;
21
+
22
+ function folderOf(path: string): string {
23
+ return path.slice(0, Math.max(0, path.lastIndexOf('/')));
24
+ }
25
+
26
+ interface LineProps {
27
+ row: CallRow;
28
+ id: string;
29
+ active: boolean;
30
+ expanded?: boolean;
31
+ position?: number;
32
+ size?: number;
33
+ ref?: Ref<HTMLDivElement>;
34
+ children: ReactNode;
35
+ onClick(): void;
36
+ onDoubleClick?(): void;
37
+ }
38
+
39
+ /* One line of the tree. Status lines are treeitems too, so the level of a symbol's calls is read the same way, but nothing selects them. */
40
+ function Line({ row, id, active, expanded, position, size, ref, children, onClick, onDoubleClick }: LineProps) {
41
+ return (
42
+ <div
43
+ ref={ref}
44
+ id={id}
45
+ role="treeitem"
46
+ aria-level={row.level}
47
+ aria-selected={active}
48
+ aria-expanded={expanded}
49
+ aria-posinset={position}
50
+ aria-setsize={size}
51
+ aria-disabled={row.kind === 'status' ? true : undefined}
52
+ data-active={active}
53
+ className="flex h-[22px] w-full cursor-default items-center gap-1.5 pr-3 text-left font-mono text-code hover:bg-surface-hover data-[active=true]:bg-accent-soft"
54
+ style={{ paddingLeft: BASE_INDENT + (row.level - 1) * INDENT }}
55
+ onClick={onClick}
56
+ onDoubleClick={onDoubleClick}
57
+ >
58
+ {children}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function Symbol({ language, row, active, id, ref }: { language: EditorLanguage; row: NodeRow; active: boolean; id: string; ref?: Ref<HTMLDivElement> }) {
64
+ const { t } = useTranslation('editor');
65
+ const { item } = row;
66
+ const path = relativeTo(fileUriToPath(item.uri) ?? item.uri, language.project.folder);
67
+ const tool = language.callHierarchy;
68
+ return (
69
+ <Line
70
+ ref={ref}
71
+ row={row}
72
+ id={id}
73
+ active={active}
74
+ expanded={row.leaf ? undefined : row.expanded}
75
+ position={row.position}
76
+ size={row.size}
77
+ onClick={() => (active ? tool.go(row.id) : tool.select(row.id))}
78
+ onDoubleClick={() => tool.go(row.id)}
79
+ >
80
+ <span
81
+ aria-hidden
82
+ className="grid size-3.5 shrink-0 place-items-center text-text-muted"
83
+ onClick={(event) => {
84
+ event.stopPropagation();
85
+ tool.toggle(row.id);
86
+ }}
87
+ >
88
+ {row.leaf ? null : <Icon icon={row.expanded ? ChevronDown : ChevronRight} size={14} />}
89
+ </span>
90
+ <SymbolBadge kind={item.kind} />
91
+ <span className={`shrink-0 text-text ${item.tags?.includes(1) ? 'line-through' : ''}`}>{item.name}</span>
92
+ {item.detail !== undefined && item.detail !== '' && <span className="min-w-0 flex-1 truncate text-text-muted">{item.detail}</span>}
93
+ <span className="ml-auto flex shrink-0 items-center gap-2 pl-2 font-sans text-xs text-text-faint">
94
+ {row.sites > 1 && (
95
+ <Tooltip label={t('language.callHierarchy.sites', { count: row.sites, formatted: formatNumber(row.sites) })}>
96
+ <span className="text-text-muted">{`×${formatNumber(row.sites)}`}</span>
97
+ </Tooltip>
98
+ )}
99
+ <Tooltip label={path}>
100
+ <span className="max-w-40 truncate">{`${basenameOf(path)}:${formatNumber(item.selectionRange.start.line + 1)}`}</span>
101
+ </Tooltip>
102
+ </span>
103
+ </Line>
104
+ );
105
+ }
106
+
107
+ function Tree({ language, view }: { language: EditorLanguage; view: CallHierarchyView }) {
108
+ const { t } = useTranslation('editor');
109
+ const treeId = useId();
110
+ const activeRow = useRef<HTMLDivElement>(null);
111
+ const idOf = (row: CallRow): string => `${treeId}-${row.id}`;
112
+ const activeId = view.rows.some((row) => row.id === view.active) ? `${treeId}-${view.active}` : undefined;
113
+
114
+ useEffect(() => {
115
+ activeRow.current?.scrollIntoView({ block: 'nearest' });
116
+ }, [view.active]);
117
+
118
+ // The focus stays in the text, so the input is what names the tree and its active row.
119
+ useLayoutEffect(() => {
120
+ language.editor.setInputPopup({ controls: treeId, activeDescendant: activeId });
121
+ return () => language.editor.setInputPopup(null);
122
+ }, [language, treeId, activeId]);
123
+
124
+ return (
125
+ <div id={treeId} role="tree" aria-label={t('language.callHierarchy.label')} className="w-1/2 shrink-0 overflow-y-auto border-r border-border py-1">
126
+ {view.rows.map((row) => {
127
+ const active = row.id === view.active;
128
+ const ref = active ? activeRow : undefined;
129
+ if (row.kind === 'node') {
130
+ return <Symbol key={row.id} ref={ref} language={language} row={row} active={active} id={idOf(row)} />;
131
+ }
132
+ if (row.kind === 'more') {
133
+ const next = Math.min(row.hidden, CALL_LEVEL_LIMIT);
134
+ return (
135
+ <Line key={row.id} ref={ref} row={row} id={idOf(row)} active={active} onClick={() => language.callHierarchy.showMore(row.parent)}>
136
+ <span className="font-sans text-xs text-accent">
137
+ {t('language.callHierarchy.more', { count: next, formatted: formatNumber(next), hidden: formatNumber(row.hidden) })}
138
+ </span>
139
+ </Line>
140
+ );
141
+ }
142
+ return (
143
+ <Line key={row.id} row={row} id={idOf(row)} active={false} onClick={() => language.callHierarchy.retry(row.parent)}>
144
+ <StatusText row={row} direction={view.direction} />
145
+ </Line>
146
+ );
147
+ })}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ function StatusText({ row, direction }: { row: Extract<CallRow, { kind: 'status' }>; direction: CallDirection }) {
153
+ const { t } = useTranslation('editor');
154
+ if (row.status === 'loading') {
155
+ return (
156
+ <span className="flex items-center gap-2 font-sans text-xs text-text-muted">
157
+ <Spinner size={12} />
158
+ {t('language.callHierarchy.loading')}
159
+ </span>
160
+ );
161
+ }
162
+ if (row.status === 'failed') {
163
+ return (
164
+ <span className="min-w-0 truncate font-sans text-xs text-status-error">{t('language.callHierarchy.retry', { message: row.message ?? '' })}</span>
165
+ );
166
+ }
167
+ return (
168
+ <span className="font-sans text-xs text-text-muted">
169
+ {t(direction === 'incoming' ? 'language.callHierarchy.noIncoming' : 'language.callHierarchy.noOutgoing')}
170
+ </span>
171
+ );
172
+ }
173
+
174
+ /*
175
+ * The call hierarchy of a symbol in the row the editor leaves under the line: the tree of callers or callees on
176
+ * the left and the code of the active call on the right. Like the peek it portals into that row, which the
177
+ * editor makes again when it scrolls back into view.
178
+ */
179
+ export function CallHierarchyPanel({ language, view }: { language: EditorLanguage; view: CallHierarchyView }) {
180
+ const { t } = useTranslation('editor');
181
+ if (view.container === null) {
182
+ return null;
183
+ }
184
+ const folder = language.project.folder;
185
+ const path = view.preview === null ? null : fileUriToPath(view.preview.uri);
186
+ const shown = path === null ? '' : folderOf(relativeTo(path, folder));
187
+ return createPortal(
188
+ <div
189
+ className="flex flex-col bg-surface pt-[2px] font-sans text-xs text-text"
190
+ style={{ height: CALL_HIERARCHY_HEIGHT }}
191
+ onPointerDown={(event) => event.preventDefault()}
192
+ >
193
+ <div className="peek-hatch h-[14px]" />
194
+ <div className="flex h-[33px] shrink-0 items-center gap-2 border-b border-border pr-2 pl-3 text-sm">
195
+ <Segmented
196
+ value={view.direction}
197
+ onValueChange={(direction) => language.callHierarchy.setDirection(direction)}
198
+ label={t('language.callHierarchy.direction')}
199
+ options={[
200
+ { id: 'incoming', label: t('language.callHierarchy.incoming') },
201
+ { id: 'outgoing', label: t('language.callHierarchy.outgoing') }
202
+ ]}
203
+ />
204
+ <span className="shrink-0 font-medium">{view.name}</span>
205
+ <span className="shrink-0 font-medium text-text-muted">{path === null ? '' : basenameOf(path)}</span>
206
+ <PathText path={shown} className="text-text-muted" />
207
+ <div className="ml-auto flex shrink-0 items-center gap-1">
208
+ {view.sites > 1 && (
209
+ <>
210
+ <IconButton
211
+ icon={ChevronLeft}
212
+ size="xs"
213
+ label={t('language.callHierarchy.previousSite')}
214
+ onClick={() => language.callHierarchy.stepSite(-1)}
215
+ />
216
+ <span className="text-text-muted">
217
+ {t('language.callHierarchy.site', { current: formatNumber(view.site + 1), total: formatNumber(view.sites) })}
218
+ </span>
219
+ <IconButton
220
+ icon={ChevronRight}
221
+ size="xs"
222
+ label={t('language.callHierarchy.nextSite')}
223
+ onClick={() => language.callHierarchy.stepSite(1)}
224
+ />
225
+ </>
226
+ )}
227
+ <IconButton icon={X} size="xs" label={t('language.callHierarchy.close')} onClick={() => language.callHierarchy.close()} />
228
+ </div>
229
+ </div>
230
+ <div className="flex h-[180px] shrink-0">
231
+ <Tree language={language} view={view} />
232
+ <Preview preview={view.preview} />
233
+ </div>
234
+ <div className="peek-hatch h-[14px]" />
235
+ </div>,
236
+ view.container
237
+ );
238
+ }
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, useState } from 'react';
1
+ import { useId, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { EditorRect } from '@adecore/editor';
@@ -64,6 +64,7 @@ const ROW_CHROME = 72;
64
64
  */
65
65
  export function CompletionPopup({ language, view, rect }: { language: EditorLanguage; view: CompletionView; rect: EditorRect }) {
66
66
  const { t } = useTranslation('editor');
67
+ const listId = useId();
67
68
  const list = useRef<HTMLDivElement>(null);
68
69
  const docs = useRef<HTMLDivElement>(null);
69
70
  const activeRow = useRef<HTMLButtonElement>(null);
@@ -114,6 +115,14 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
114
115
 
115
116
  const total = view.rows.length;
116
117
  const { first, last } = rowWindow(listTop, view.active, total, ROW_HEIGHT, VISIBLE_ROWS, OVERSCAN_ROWS, LIST_PADDING);
118
+ const optionId = (index: number): string => `${listId}-option-${index}`;
119
+ const activeId = view.active >= first && view.active < last ? optionId(view.active) : undefined;
120
+
121
+ // The focus stays in the text, so the input is what names the list and its active option.
122
+ useLayoutEffect(() => {
123
+ language.editor.setInputPopup({ controls: listId, activeDescendant: activeId });
124
+ return () => language.editor.setInputPopup(null);
125
+ }, [language, listId, activeId]);
117
126
 
118
127
  return createPortal(
119
128
  <>
@@ -132,6 +141,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
132
141
  }}
133
142
  />
134
143
  <div
144
+ id={listId}
135
145
  role="listbox"
136
146
  className="w-0 min-w-full overflow-y-auto p-1"
137
147
  style={{ maxHeight: ROW_HEIGHT * VISIBLE_ROWS + 8 }}
@@ -143,6 +153,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
143
153
  return (
144
154
  <button
145
155
  key={`${index}:${row.label}`}
156
+ id={optionId(index)}
146
157
  ref={index === view.active ? activeRow : undefined}
147
158
  type="button"
148
159
  role="option"
@@ -0,0 +1,262 @@
1
+ import { useLayoutEffect, useMemo, useRef, useState, type Ref } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import type { Editor, EditorEngine, EditorLineHighlight, EditorTheme } from '@adecore/editor';
4
+ import { parseChord } from '@adecore/editor/keymap';
5
+ import { diffDisplay, diffSplit, type DiffDocument, type DiffDisplay } from '@adecore/merge';
6
+ import { wordMarks } from './diff-wordmarks.ts';
7
+ import { DiffOverlays, type MountedDiff } from './diff-overlays.tsx';
8
+ import { SplitDiffView } from './SplitDiffView.tsx';
9
+ import type { DiffSourceTokens } from './diff-syntax.ts';
10
+ import { LineActionHost, RowHost } from './row-host.ts';
11
+ import { DEFAULT_UNFOLD_LIMITS, expandDiff, type DiffLimits } from './diff-expansion.ts';
12
+
13
+ export interface DiffViewProps {
14
+ engine: EditorEngine;
15
+ document: DiffDocument;
16
+ theme: EditorTheme;
17
+ tokens?: DiffSourceTokens;
18
+ wrap?: boolean;
19
+ layout?: 'unified' | 'split';
20
+ /* Above this many displayed unified rows, split uses fixed line heights. Defaults to 2000. */
21
+ splitWrapLimit?: number;
22
+ /* A fixed-height host supplies the height; otherwise the view grows with its rows. */
23
+ fill?: boolean;
24
+ /* Positive pixels; taller content scrolls inside the editor. Ignored with fill. */
25
+ maxHeight?: number;
26
+ /* Total visible source-text budgets after unfolding; changed rows are always retained. */
27
+ unfoldLimits?: DiffLimits;
28
+ label: string;
29
+ className?: string;
30
+ ref?: Ref<HTMLDivElement>;
31
+ onMount?(editor: Editor, side?: 'old' | 'new'): void | (() => void);
32
+ }
33
+
34
+ interface UnifiedViewProps extends DiffViewProps {
35
+ display: DiffDisplay;
36
+ unfold(id: number): void;
37
+ blocked: ReadonlySet<number>;
38
+ }
39
+
40
+ function UnifiedDiffView({
41
+ engine,
42
+ document,
43
+ display,
44
+ unfold,
45
+ blocked,
46
+ theme,
47
+ tokens,
48
+ wrap = true,
49
+ fill = false,
50
+ maxHeight,
51
+ label,
52
+ className,
53
+ ref,
54
+ onMount
55
+ }: UnifiedViewProps) {
56
+ const { t } = useTranslation('editor');
57
+ const emphasis = useMemo(() => wordMarks(document), [document]);
58
+ const element = useRef<HTMLDivElement>(null);
59
+ const current = useRef({ display, tokens, theme, wrap, label });
60
+ const applied = useRef({ theme, wrap, label });
61
+ const [mounted, setMounted] = useState<MountedDiff | null>(null);
62
+ const [height, setHeight] = useState(0);
63
+ useLayoutEffect(() => {
64
+ current.current = { display, tokens, theme, wrap, label };
65
+ });
66
+ useLayoutEffect(() => {
67
+ const host = element.current;
68
+ if (host === null) {
69
+ return;
70
+ }
71
+ const initial = current.current;
72
+ const editor = engine.mount(host, {
73
+ text: initial.display.text,
74
+ theme: initial.theme,
75
+ wrap: initial.wrap,
76
+ label: initial.label,
77
+ readOnly: true,
78
+ guides: false,
79
+ foldOutline: 'off',
80
+ folding: false,
81
+ // Prompt cards use this chord for their primary action; a read-only review has no split-line action.
82
+ handBack: [parseChord('Mod+Enter')]
83
+ });
84
+ applied.current = { theme: initial.theme, wrap: initial.wrap, label: initial.label };
85
+ editor.setBlocks([]);
86
+ editor.setLineTokens((line) => {
87
+ const { display, tokens } = current.current;
88
+ const row = display.rows[line];
89
+ return row === undefined ? null : tokens?.get(row.new === undefined ? 'old' : 'new', (row.new ?? row.old)!.index);
90
+ });
91
+ setMounted({ engine, editor, rows: new RowHost(editor, 'diff-gaps'), actions: new LineActionHost(editor, 'diff-status') });
92
+ const measure = (): void => setHeight(editor.getContentHeight());
93
+ const stop = editor.onViewChange(measure);
94
+ measure();
95
+ return () => {
96
+ stop();
97
+ editor.dispose();
98
+ };
99
+ }, [engine]);
100
+ useLayoutEffect(() => {
101
+ if (mounted === null || mounted.engine !== engine) {
102
+ return;
103
+ }
104
+ const { editor, rows, actions } = mounted;
105
+ editor.setText(display.text);
106
+ editor.setLineNumbers((line) => {
107
+ const row = current.current.display.rows[line];
108
+ return row === undefined ? null : String((row.new ?? row.old)!.number);
109
+ });
110
+ const highlights: EditorLineHighlight[] = [];
111
+ for (const [index, row] of display.rows.entries()) {
112
+ if (row.change !== 'delete' && row.change !== 'insert') {
113
+ continue;
114
+ }
115
+ const removed = row.change === 'delete';
116
+ highlights.push({
117
+ startLine: index + 1,
118
+ endLine: index + 1,
119
+ color: removed ? '--editor-deleted' : '--editor-added',
120
+ bar: removed ? 'hatched' : 'solid',
121
+ emphasis: [(removed ? emphasis.old.get(row.old!.index) : emphasis.new.get(row.new!.index)) ?? []]
122
+ });
123
+ }
124
+ editor.setLineHighlights(highlights);
125
+ editor.setChangeMarks(
126
+ highlights.map((highlight) => ({
127
+ startLine: highlight.startLine,
128
+ endLine: highlight.endLine,
129
+ kind: highlight.bar === 'hatched' ? 'deleted' : 'added'
130
+ }))
131
+ );
132
+ rows.set(
133
+ display.gaps.map(({ before, row }) => ({
134
+ id: `gap-${row.id}`,
135
+ line: Math.min(before, Math.max(0, display.rows.length - 1)),
136
+ placement: before < display.rows.length ? 'above' : 'below',
137
+ height: 32
138
+ }))
139
+ );
140
+ actions.set(display.rows.flatMap((row, line) => (row.noNewline === undefined ? [] : [{ id: `no-newline-${line}`, line }])));
141
+ }, [mounted, engine, display, emphasis]);
142
+ useLayoutEffect(() => {
143
+ if (mounted === null || mounted.engine !== engine) {
144
+ return;
145
+ }
146
+ // The color of a changed line is not the only thing that says so: the gutter carries a sentence with the numbers it reads from.
147
+ mounted.editor.setLineLabels((line) => {
148
+ const row = display.rows[line];
149
+ if (row?.change === 'delete') {
150
+ return t('diff.removed', { number: row.old!.number });
151
+ }
152
+ if (row?.change === 'insert') {
153
+ return t('diff.added', { number: row.new!.number });
154
+ }
155
+ return row?.old !== undefined && row.new !== undefined ? t('diff.unchanged', { old: row.old.number, new: row.new.number }) : null;
156
+ });
157
+ }, [mounted, engine, display, t]);
158
+ useLayoutEffect(() => {
159
+ if (mounted === null || mounted.engine !== engine) {
160
+ return;
161
+ }
162
+ const previous = applied.current;
163
+ if (previous.theme !== theme) {
164
+ mounted.editor.setTheme(theme);
165
+ }
166
+ if (previous.wrap !== wrap) {
167
+ mounted.editor.setWrap(wrap);
168
+ }
169
+ if (previous.label !== label) {
170
+ mounted.editor.setLabel(label);
171
+ }
172
+ applied.current = { theme, wrap, label };
173
+ }, [mounted, engine, theme, wrap, label]);
174
+ useLayoutEffect(() => {
175
+ if (mounted === null || mounted.engine !== engine) {
176
+ return;
177
+ }
178
+ const refresh = (): void => {
179
+ mounted.editor.setLineTokens((line) => {
180
+ const row = current.current.display.rows[line];
181
+ return row === undefined ? null : current.current.tokens?.get(row.new === undefined ? 'old' : 'new', (row.new ?? row.old)!.index);
182
+ });
183
+ };
184
+ refresh();
185
+ const ownerWindow = element.current?.ownerDocument.defaultView;
186
+ let pending = false;
187
+ let stopped = false;
188
+ let frame: number | undefined;
189
+ const schedule = (): void => {
190
+ if (pending) {
191
+ return;
192
+ }
193
+ pending = true;
194
+ const paint = (): void => {
195
+ pending = false;
196
+ if (!stopped) {
197
+ refresh();
198
+ }
199
+ };
200
+ if (ownerWindow?.requestAnimationFrame !== undefined) {
201
+ frame = ownerWindow.requestAnimationFrame(paint);
202
+ } else {
203
+ queueMicrotask(paint);
204
+ }
205
+ };
206
+ const stop = tokens?.subscribe(schedule);
207
+ return () => {
208
+ stopped = true;
209
+ stop?.();
210
+ if (frame !== undefined) {
211
+ ownerWindow?.cancelAnimationFrame?.(frame);
212
+ }
213
+ };
214
+ }, [mounted, engine, tokens, display]);
215
+ useLayoutEffect(() => {
216
+ if (mounted !== null && mounted.engine === engine) {
217
+ return onMount?.(mounted.editor);
218
+ }
219
+ }, [mounted, engine, onMount]);
220
+ const limit = maxHeight !== undefined && Number.isFinite(maxHeight) && maxHeight > 0 ? maxHeight : Infinity;
221
+ return (
222
+ <div
223
+ className={`adecore-editor-diff ${className ?? ''}`}
224
+ ref={ref}
225
+ data-scroll={fill || height > limit}
226
+ style={{ position: 'relative', minHeight: 0, height: fill ? '100%' : Math.min(height, limit) }}
227
+ >
228
+ <div ref={element} style={{ position: 'absolute', inset: 0 }} />
229
+ {mounted !== null && mounted.engine === engine && <DiffOverlays mounted={mounted} display={display} unfold={unfold} blocked={blocked} />}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ export function DiffView(props: DiffViewProps) {
235
+ const { document, unfoldLimits = DEFAULT_UNFOLD_LIMITS } = props;
236
+ const [expanded, setExpanded] = useState<{ document: DiffDocument; gaps: ReadonlyMap<number, number>; blocked: ReadonlySet<number> }>(() => ({
237
+ document,
238
+ gaps: new Map(),
239
+ blocked: new Set()
240
+ }));
241
+ const gaps = expanded.document === document ? expanded.gaps : undefined;
242
+ const blocked = expanded.document === document ? expanded.blocked : new Set<number>();
243
+ const projection = useMemo(() => (props.layout === 'split' ? diffSplit(document, gaps) : diffDisplay(document, gaps)), [props.layout, document, gaps]);
244
+ const unfold = (id: number): void =>
245
+ setExpanded((previous) => {
246
+ const gaps = previous.document === document ? previous.gaps : new Map<number, number>();
247
+ const next = expandDiff(document, gaps, id, unfoldLimits);
248
+ return {
249
+ document,
250
+ gaps: next,
251
+ blocked: new Set([...(previous.document === document ? previous.blocked : []), ...(next === gaps ? [id] : [])])
252
+ };
253
+ });
254
+ if ('aligned' in projection && projection.old.lines.length > 0 && projection.new.lines.length > 0) {
255
+ const visibleRows = projection.aligned.reduce((count, row) => count + (row.kind === 'gap' ? 0 : row.row.change === 'pair' ? 2 : 1), 0);
256
+ const wrap = props.wrap !== false && visibleRows <= (props.splitWrapLimit ?? 2000);
257
+ return <SplitDiffView {...props} wrap={wrap} display={projection} unfold={unfold} blocked={blocked} />;
258
+ }
259
+ // An absent side has no selectable text; keep additions and deletions in the unified editor.
260
+ const display = 'aligned' in projection ? (projection.old.lines.length > 0 ? projection.old : projection.new) : projection;
261
+ return <UnifiedDiffView {...props} display={display} unfold={unfold} blocked={blocked} />;
262
+ }
@@ -17,6 +17,7 @@ export function EditorContextMenu({ language, view, children }: { language: Edit
17
17
  { id: 'goToTypeDefinition', method: 'textDocument/typeDefinition', run: () => void language.navigation.go('typeDefinition') },
18
18
  { id: 'goToImplementation', method: 'textDocument/implementation', run: () => void language.navigation.go('implementation') },
19
19
  { id: 'peekReferences', method: 'textDocument/references', run: () => void language.peek.open() },
20
+ { id: 'callHierarchy', method: 'textDocument/prepareCallHierarchy', run: () => void language.callHierarchy.open() },
20
21
  { id: 'renameSymbol', method: 'textDocument/rename', run: () => void language.rename.start() },
21
22
  { id: 'codeActions', method: 'textDocument/codeAction', run: () => void language.codeActions.open() }
22
23
  ];
@@ -1,6 +1,8 @@
1
1
  import { createHolder } from './holder.ts';
2
2
  import { useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode, type Ref } from 'react';
3
- import type { Editor, EditorEngine, EditorOptions } from '@adecore/editor';
3
+ import { useTranslation } from 'react-i18next';
4
+ import type { TFunction } from 'i18next';
5
+ import type { Editor, EditorEngine, EditorMessages, EditorOptions } from '@adecore/editor';
4
6
  import { EditorLanguage } from './editor-language.ts';
5
7
  import { LanguagePopups } from './LanguagePopups.tsx';
6
8
  import type { ProjectLanguage } from './project-language.ts';
@@ -18,8 +20,17 @@ export interface EditorViewProps {
18
20
  contextMenuItems?(language: EditorLanguage): ReactNode;
19
21
  }
20
22
 
23
+ /* The names of the fold controls in the editor's `{start}` and `{end}` placeholders, which the editor fills in per control. */
24
+ function foldMessages(t: TFunction<'editor'>): Pick<EditorMessages, 'fold' | 'unfold'> {
25
+ const lines = { start: '{start}', end: '{end}' };
26
+ return { fold: t('language.fold.fold', lines), unfold: t('language.fold.unfold', lines) };
27
+ }
28
+
21
29
  export function EditorView({ engine, options, project, uri, languageId, className, ref, onMount, contextMenuItems }: EditorViewProps) {
30
+ const { t } = useTranslation('editor');
22
31
  const element = useRef<HTMLDivElement>(null);
32
+ const mounted = useRef<Editor | null>(null);
33
+ const words = useRef(t);
23
34
  const holder = useMemo(() => createHolder<EditorLanguage>(), []);
24
35
  const language = useSyncExternalStore(holder.subscribe, holder.get, holder.get);
25
36
  useLayoutEffect(() => {
@@ -27,17 +38,25 @@ export function EditorView({ engine, options, project, uri, languageId, classNam
27
38
  if (host === null) {
28
39
  return;
29
40
  }
30
- const editor = engine.mount(host, options);
41
+ const editor = engine.mount(host, { ...options, messages: { ...foldMessages(words.current), ...options.messages } });
42
+ mounted.current = editor;
31
43
  const attached = project === undefined || uri === undefined || languageId === undefined ? null : new EditorLanguage(project, editor, uri, languageId);
32
44
  holder.set(attached);
33
45
  const stop = onMount?.(editor, attached);
34
46
  return () => {
47
+ mounted.current = null;
35
48
  holder.set(null);
36
49
  stop?.();
37
50
  attached?.dispose();
38
51
  editor.dispose();
39
52
  };
40
53
  }, [engine, options, project, uri, languageId, holder, onMount]);
54
+ useLayoutEffect(() => {
55
+ words.current = t;
56
+ const { fold, unfold } = options.messages ?? {};
57
+ const localized = foldMessages(t);
58
+ mounted.current?.setMessages({ fold: fold ?? localized.fold, unfold: unfold ?? localized.unfold });
59
+ }, [t, options.messages]);
41
60
  return (
42
61
  <div className={className} ref={ref} style={{ position: 'relative', minHeight: 0 }}>
43
62
  <div ref={element} style={{ position: 'absolute', inset: 0 }} />