@adecore/editor-react 0.0.1 → 0.17.0-beta.2

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 (264) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +60 -0
  3. package/dist/AnchoredPopup.d.ts +12 -0
  4. package/dist/AnchoredPopup.js +27 -0
  5. package/dist/AttributionCard.d.ts +14 -0
  6. package/dist/AttributionCard.js +5 -0
  7. package/dist/BaselineStatus.d.ts +4 -0
  8. package/dist/BaselineStatus.js +13 -0
  9. package/dist/ChangeReview.d.ts +12 -0
  10. package/dist/ChangeReview.js +27 -0
  11. package/dist/CodeAuthorsCard.d.ts +6 -0
  12. package/dist/CodeAuthorsCard.js +22 -0
  13. package/dist/CompletionPopup.d.ts +8 -0
  14. package/dist/CompletionPopup.js +103 -0
  15. package/dist/EditorContextMenu.d.ts +8 -0
  16. package/dist/EditorContextMenu.js +23 -0
  17. package/dist/EditorView.d.ts +16 -0
  18. package/dist/EditorView.js +27 -0
  19. package/dist/FindReplace.d.ts +6 -0
  20. package/dist/FindReplace.js +44 -0
  21. package/dist/HoverCard.d.ts +11 -0
  22. package/dist/HoverCard.js +38 -0
  23. package/dist/HoverSections.d.ts +21 -0
  24. package/dist/HoverSections.js +82 -0
  25. package/dist/LanguagePopups.d.ts +6 -0
  26. package/dist/LanguagePopups.js +38 -0
  27. package/dist/Markdown.d.ts +3 -0
  28. package/dist/Markdown.js +7 -0
  29. package/dist/PathText.d.ts +4 -0
  30. package/dist/PathText.js +12 -0
  31. package/dist/PeekPanel.d.ts +6 -0
  32. package/dist/PeekPanel.js +111 -0
  33. package/dist/PickPopup.d.ts +8 -0
  34. package/dist/PickPopup.js +33 -0
  35. package/dist/RenameCard.d.ts +9 -0
  36. package/dist/RenameCard.js +108 -0
  37. package/dist/SignatureCard.d.ts +4 -0
  38. package/dist/SignatureCard.js +8 -0
  39. package/dist/SymbolPicker.d.ts +6 -0
  40. package/dist/SymbolPicker.js +108 -0
  41. package/dist/code-actions-model.d.ts +23 -0
  42. package/dist/code-actions-model.js +114 -0
  43. package/dist/code-actions.d.ts +30 -0
  44. package/dist/code-actions.js +326 -0
  45. package/dist/code-authors.d.ts +18 -0
  46. package/dist/code-authors.js +79 -0
  47. package/dist/code-vision-model.d.ts +21 -0
  48. package/dist/code-vision-model.js +183 -0
  49. package/dist/code-vision.d.ts +48 -0
  50. package/dist/code-vision.js +236 -0
  51. package/dist/completion-call.d.ts +24 -0
  52. package/dist/completion-call.js +110 -0
  53. package/dist/completion-model.d.ts +51 -0
  54. package/dist/completion-model.js +353 -0
  55. package/dist/completion.d.ts +43 -0
  56. package/dist/completion.js +463 -0
  57. package/dist/conflict-model.d.ts +15 -0
  58. package/dist/conflict-model.js +36 -0
  59. package/dist/context-menu.d.ts +10 -0
  60. package/dist/context-menu.js +55 -0
  61. package/dist/definition-link.d.ts +14 -0
  62. package/dist/definition-link.js +99 -0
  63. package/dist/diagnostics-model.d.ts +15 -0
  64. package/dist/diagnostics-model.js +83 -0
  65. package/dist/diagnostics.d.ts +23 -0
  66. package/dist/diagnostics.js +95 -0
  67. package/dist/editor-language.d.ts +65 -0
  68. package/dist/editor-language.js +170 -0
  69. package/dist/editor-react.css +43 -0
  70. package/dist/find-query.d.ts +26 -0
  71. package/dist/find-query.js +51 -0
  72. package/dist/folding.d.ts +16 -0
  73. package/dist/folding.js +95 -0
  74. package/dist/highlight-layers.d.ts +10 -0
  75. package/dist/highlight-layers.js +30 -0
  76. package/dist/highlights.d.ts +16 -0
  77. package/dist/highlights.js +102 -0
  78. package/dist/history.d.ts +8 -0
  79. package/dist/history.js +46 -0
  80. package/dist/holder.d.ts +5 -0
  81. package/dist/holder.js +19 -0
  82. package/dist/host-types.d.ts +45 -0
  83. package/dist/host-types.js +1 -0
  84. package/dist/hover-content.d.ts +45 -0
  85. package/dist/hover-content.js +198 -0
  86. package/dist/hover.d.ts +32 -0
  87. package/dist/hover.js +260 -0
  88. package/dist/index.d.ts +44 -0
  89. package/dist/index.js +28 -0
  90. package/dist/inlay-hints.d.ts +13 -0
  91. package/dist/inlay-hints.js +71 -0
  92. package/dist/language-ids.d.ts +3 -0
  93. package/dist/language-ids.js +49 -0
  94. package/dist/line-edits.d.ts +8 -0
  95. package/dist/line-edits.js +39 -0
  96. package/dist/locales/en.json +171 -0
  97. package/dist/locales/nl.json +171 -0
  98. package/dist/models.d.ts +20 -0
  99. package/dist/models.js +20 -0
  100. package/dist/navigation-history.d.ts +16 -0
  101. package/dist/navigation-history.js +53 -0
  102. package/dist/navigation.d.ts +23 -0
  103. package/dist/navigation.js +155 -0
  104. package/dist/paths.d.ts +1 -0
  105. package/dist/paths.js +3 -0
  106. package/dist/peek-model.d.ts +28 -0
  107. package/dist/peek-model.js +104 -0
  108. package/dist/peek.d.ts +30 -0
  109. package/dist/peek.js +215 -0
  110. package/dist/pick.d.ts +34 -0
  111. package/dist/pick.js +166 -0
  112. package/dist/popup-placement.d.ts +23 -0
  113. package/dist/popup-placement.js +34 -0
  114. package/dist/popups.d.ts +133 -0
  115. package/dist/popups.js +14 -0
  116. package/dist/project-language.d.ts +36 -0
  117. package/dist/project-language.js +190 -0
  118. package/dist/project-problems.d.ts +24 -0
  119. package/dist/project-problems.js +63 -0
  120. package/dist/proposal-model.d.ts +36 -0
  121. package/dist/proposal-model.js +197 -0
  122. package/dist/recent-choices.d.ts +10 -0
  123. package/dist/recent-choices.js +39 -0
  124. package/dist/refresher.d.ts +13 -0
  125. package/dist/refresher.js +50 -0
  126. package/dist/relative-time.d.ts +1 -0
  127. package/dist/relative-time.js +9 -0
  128. package/dist/rename-model.d.ts +25 -0
  129. package/dist/rename-model.js +93 -0
  130. package/dist/rename.d.ts +21 -0
  131. package/dist/rename.js +257 -0
  132. package/dist/rendering-context.d.ts +6 -0
  133. package/dist/rendering-context.js +5 -0
  134. package/dist/rendering.d.ts +6 -0
  135. package/dist/rendering.js +5 -0
  136. package/dist/review-group.d.ts +16 -0
  137. package/dist/review-group.js +54 -0
  138. package/dist/row-host.d.ts +42 -0
  139. package/dist/row-host.js +120 -0
  140. package/dist/selection-ranges.d.ts +7 -0
  141. package/dist/selection-ranges.js +26 -0
  142. package/dist/semantic-model.d.ts +4 -0
  143. package/dist/semantic-model.js +76 -0
  144. package/dist/semantic-tokens.d.ts +6 -0
  145. package/dist/semantic-tokens.js +38 -0
  146. package/dist/shortcut-keys.d.ts +91 -0
  147. package/dist/shortcut-keys.js +12 -0
  148. package/dist/signature-model.d.ts +18 -0
  149. package/dist/signature-model.js +33 -0
  150. package/dist/signature.d.ts +22 -0
  151. package/dist/signature.js +150 -0
  152. package/dist/snippet-session.d.ts +17 -0
  153. package/dist/snippet-session.js +110 -0
  154. package/dist/snippet.d.ts +11 -0
  155. package/dist/snippet.js +134 -0
  156. package/dist/symbol-links.d.ts +6 -0
  157. package/dist/symbol-links.js +50 -0
  158. package/dist/symbol-picker-model.d.ts +44 -0
  159. package/dist/symbol-picker-model.js +146 -0
  160. package/dist/symbol-picker.d.ts +12 -0
  161. package/dist/symbol-picker.js +76 -0
  162. package/dist/symbols.d.ts +11 -0
  163. package/dist/symbols.js +95 -0
  164. package/dist/testing.d.ts +57 -0
  165. package/dist/testing.js +156 -0
  166. package/dist/timers.d.ts +14 -0
  167. package/dist/timers.js +34 -0
  168. package/dist/use-problem-counts.d.ts +3 -0
  169. package/dist/use-problem-counts.js +20 -0
  170. package/dist/view-state.d.ts +21 -0
  171. package/dist/view-state.js +63 -0
  172. package/dist/word-diff.d.ts +1 -0
  173. package/dist/word-diff.js +32 -0
  174. package/dist/workspace-edit.d.ts +21 -0
  175. package/dist/workspace-edit.js +147 -0
  176. package/package.json +74 -2
  177. package/src/AnchoredPopup.tsx +51 -0
  178. package/src/AttributionCard.tsx +36 -0
  179. package/src/BaselineStatus.tsx +20 -0
  180. package/src/ChangeReview.tsx +43 -0
  181. package/src/CodeAuthorsCard.tsx +81 -0
  182. package/src/CompletionPopup.tsx +196 -0
  183. package/src/EditorContextMenu.tsx +67 -0
  184. package/src/EditorView.tsx +47 -0
  185. package/src/FindReplace.tsx +89 -0
  186. package/src/HoverCard.tsx +121 -0
  187. package/src/HoverSections.tsx +146 -0
  188. package/src/LanguagePopups.tsx +65 -0
  189. package/src/Markdown.tsx +11 -0
  190. package/src/PathText.tsx +18 -0
  191. package/src/PeekPanel.tsx +222 -0
  192. package/src/PickPopup.tsx +102 -0
  193. package/src/RenameCard.tsx +219 -0
  194. package/src/SignatureCard.tsx +42 -0
  195. package/src/SymbolPicker.tsx +219 -0
  196. package/src/code-actions-model.ts +146 -0
  197. package/src/code-actions.ts +384 -0
  198. package/src/code-authors.ts +104 -0
  199. package/src/code-vision-model.ts +230 -0
  200. package/src/code-vision.ts +269 -0
  201. package/src/completion-call.ts +139 -0
  202. package/src/completion-model.ts +440 -0
  203. package/src/completion.ts +500 -0
  204. package/src/conflict-model.ts +56 -0
  205. package/src/context-menu.ts +63 -0
  206. package/src/definition-link.ts +106 -0
  207. package/src/diagnostics-model.ts +102 -0
  208. package/src/diagnostics.ts +117 -0
  209. package/src/editor-language.ts +189 -0
  210. package/src/editor-react.css +43 -0
  211. package/src/find-query.ts +74 -0
  212. package/src/folding.ts +110 -0
  213. package/src/highlight-layers.ts +37 -0
  214. package/src/highlights.ts +123 -0
  215. package/src/history.ts +56 -0
  216. package/src/holder.ts +19 -0
  217. package/src/host-types.ts +52 -0
  218. package/src/hover-content.ts +264 -0
  219. package/src/hover.ts +280 -0
  220. package/src/index.ts +63 -0
  221. package/src/inlay-hints.ts +85 -0
  222. package/src/language-ids.ts +54 -0
  223. package/src/line-edits.ts +48 -0
  224. package/src/locales/en.json +171 -0
  225. package/src/locales/nl.json +171 -0
  226. package/src/models.ts +157 -0
  227. package/src/navigation-history.ts +69 -0
  228. package/src/navigation.ts +168 -0
  229. package/src/paths.ts +3 -0
  230. package/src/peek-model.ts +147 -0
  231. package/src/peek.ts +247 -0
  232. package/src/pick.ts +197 -0
  233. package/src/popup-placement.ts +64 -0
  234. package/src/popups.ts +204 -0
  235. package/src/project-language.ts +223 -0
  236. package/src/project-problems.ts +87 -0
  237. package/src/proposal-model.ts +259 -0
  238. package/src/recent-choices.ts +48 -0
  239. package/src/refresher.ts +56 -0
  240. package/src/relative-time.ts +10 -0
  241. package/src/rename-model.ts +130 -0
  242. package/src/rename.ts +290 -0
  243. package/src/rendering-context.ts +12 -0
  244. package/src/rendering.tsx +6 -0
  245. package/src/review-group.ts +70 -0
  246. package/src/row-host.ts +158 -0
  247. package/src/selection-ranges.ts +32 -0
  248. package/src/semantic-model.ts +80 -0
  249. package/src/semantic-tokens.ts +46 -0
  250. package/src/shortcut-keys.ts +21 -0
  251. package/src/signature-model.ts +49 -0
  252. package/src/signature.ts +164 -0
  253. package/src/snippet-session.ts +122 -0
  254. package/src/snippet.ts +147 -0
  255. package/src/symbol-links.ts +72 -0
  256. package/src/symbol-picker-model.ts +198 -0
  257. package/src/symbol-picker.ts +85 -0
  258. package/src/symbols.ts +110 -0
  259. package/src/testing.ts +240 -0
  260. package/src/timers.ts +45 -0
  261. package/src/use-problem-counts.ts +25 -0
  262. package/src/view-state.ts +88 -0
  263. package/src/word-diff.ts +35 -0
  264. package/src/workspace-edit.ts +194 -0
@@ -0,0 +1,196 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import type { EditorRect } from '@adecore/editor';
5
+ import type { EditorLanguage } from './editor-language.ts';
6
+ import { kindLetterOf, kindToneOf, rowWindow, type KindTone } from './completion-model.ts';
7
+ import { SourceLine, SymbolSections } from './HoverSections.tsx';
8
+ import { PathText } from './PathText.tsx';
9
+ import { placeBeside, placePopup } from './popup-placement.ts';
10
+ import type { CompletionView } from './popups.ts';
11
+
12
+ const TONES: Record<KindTone, string> = {
13
+ callable: 'bg-accent-soft text-accent',
14
+ value: 'bg-status-running/15 text-status-running',
15
+ type: 'bg-status-idle/15 text-status-idle',
16
+ other: 'bg-surface-hover text-text-muted'
17
+ };
18
+
19
+ /* The label with the characters that match what was typed set apart. */
20
+ function MarkedLabel({ label, matches }: { label: string; matches: readonly number[] }) {
21
+ if (matches.length === 0) {
22
+ return <>{label}</>;
23
+ }
24
+ const marked = new Set(matches);
25
+ const runs: { text: string; marked: boolean }[] = [];
26
+ for (let at = 0; at < label.length; at++) {
27
+ const isMarked = marked.has(at);
28
+ const last = runs[runs.length - 1];
29
+ if (last !== undefined && last.marked === isMarked) {
30
+ last.text += label[at];
31
+ } else {
32
+ runs.push({ text: label[at]!, marked: isMarked });
33
+ }
34
+ }
35
+ return (
36
+ <>
37
+ {runs.map((run, index) =>
38
+ run.marked ? (
39
+ <span key={index} className="font-semibold text-accent">
40
+ {run.text}
41
+ </span>
42
+ ) : (
43
+ run.text
44
+ )
45
+ )}
46
+ </>
47
+ );
48
+ }
49
+
50
+ const CARD = 'fixed top-0 left-0 z-(--z-popup) overflow-hidden rounded-lg border border-border bg-surface-raised text-text shadow-(--float-shadow)';
51
+ const ROW_HEIGHT = 24;
52
+ const VISIBLE_ROWS = 8;
53
+ /* Rows drawn past the visible ones on each side, so a wheel turn lands on rows that exist. */
54
+ const OVERSCAN_ROWS = 6;
55
+ /* The padding the listbox has around its rows. */
56
+ const LIST_PADDING = 4;
57
+ /* What a row takes besides its name and namespace: padding, the kind badge and the gaps. */
58
+ const ROW_CHROME = 72;
59
+
60
+ /*
61
+ * The suggestions under the word being typed: a narrow list with the parameters of each, and the
62
+ * documentation of the active one beside it, or under it when the window has no room at its side. A
63
+ * press on a row never takes the focus from the editor, so typing goes on while the list is open.
64
+ */
65
+ export function CompletionPopup({ language, view, rect }: { language: EditorLanguage; view: CompletionView; rect: EditorRect }) {
66
+ const { t } = useTranslation('editor');
67
+ const list = useRef<HTMLDivElement>(null);
68
+ const docs = useRef<HTMLDivElement>(null);
69
+ const activeRow = useRef<HTMLButtonElement>(null);
70
+ // The widest row so far in characters, which only grows, so the list never narrows or jumps while it is typed into.
71
+ const [widest, setWidest] = useState(0);
72
+ // Only the rows near the visible ones are drawn: a list of 150 is mostly out of sight, and every keystroke makes it again.
73
+ const [listTop, setListTop] = useState(0);
74
+ const need = Math.max(0, ...view.rows.slice(0, VISIBLE_ROWS * 2).map((row) => row.label.length + 2 + row.description.length));
75
+ if (need > widest) {
76
+ setWidest(need);
77
+ }
78
+
79
+ // Every render: the word moves when the editor scrolls, and the list and the documentation change size with what they hold.
80
+ useLayoutEffect(() => {
81
+ const card = list.current;
82
+ if (card === null) {
83
+ return;
84
+ }
85
+ const viewport = {
86
+ width: window.innerWidth,
87
+ height: window.innerHeight
88
+ };
89
+ const side = docs.current;
90
+ // Every size is read before the first write, so the page is laid out once and not after each of them.
91
+ const size = { width: card.offsetWidth, height: card.offsetHeight };
92
+ const sideSize = side === null ? null : { width: side.offsetWidth, height: side.offsetHeight };
93
+ const placed = placePopup(rect, size, viewport, { gap: 2 });
94
+ card.style.left = `${Math.round(placed.left)}px`;
95
+ card.style.top = `${Math.round(placed.top)}px`;
96
+ card.style.visibility = 'visible';
97
+ if (side !== null && sideSize !== null) {
98
+ const box = {
99
+ left: Math.round(placed.left),
100
+ top: Math.round(placed.top),
101
+ ...size
102
+ };
103
+ const beside = placeBeside(box, sideSize, viewport);
104
+ side.style.left = `${Math.round(beside.left)}px`;
105
+ side.style.top = `${Math.round(beside.top)}px`;
106
+ side.style.maxHeight = `${Math.floor(beside.maxHeight)}px`;
107
+ side.style.visibility = 'visible';
108
+ }
109
+ });
110
+
111
+ useLayoutEffect(() => {
112
+ activeRow.current?.scrollIntoView({ block: 'nearest' });
113
+ }, [view.active, view.rows]);
114
+
115
+ const total = view.rows.length;
116
+ const { first, last } = rowWindow(listTop, view.active, total, ROW_HEIGHT, VISIBLE_ROWS, OVERSCAN_ROWS, LIST_PADDING);
117
+
118
+ return createPortal(
119
+ <>
120
+ <div
121
+ ref={list}
122
+ className={`${CARD} flex w-max max-w-[min(560px,calc(100vw-16px))] min-w-[340px] flex-col`}
123
+ style={{ visibility: 'hidden' }}
124
+ onPointerDown={(event) => event.preventDefault()}
125
+ >
126
+ {/* Sizes the list to the widest rows in the face the rows use; the rows themselves fill that width and truncate inside it. */}
127
+ <div
128
+ aria-hidden
129
+ className="h-0 overflow-hidden font-mono text-code"
130
+ style={{
131
+ width: `calc(${Math.max(widest, need)}ch + ${ROW_CHROME}px)`
132
+ }}
133
+ />
134
+ <div
135
+ role="listbox"
136
+ className="w-0 min-w-full overflow-y-auto p-1"
137
+ style={{ maxHeight: ROW_HEIGHT * VISIBLE_ROWS + 8 }}
138
+ onScroll={(event) => setListTop(event.currentTarget.scrollTop)}
139
+ >
140
+ <div aria-hidden style={{ height: first * ROW_HEIGHT }} />
141
+ {view.rows.slice(first, last).map((row, offset) => {
142
+ const index = first + offset;
143
+ return (
144
+ <button
145
+ key={`${index}:${row.label}`}
146
+ ref={index === view.active ? activeRow : undefined}
147
+ type="button"
148
+ role="option"
149
+ aria-selected={index === view.active}
150
+ aria-setsize={total}
151
+ aria-posinset={index + 1}
152
+ data-active={index === view.active}
153
+ className="flex h-6 w-full items-center gap-2 overflow-hidden rounded-md px-1.5 text-left text-xs cursor-row"
154
+ onClick={() => void language.completion.accept(false, index)}
155
+ >
156
+ <span
157
+ className={`grid size-4 shrink-0 place-items-center rounded-sm font-mono text-xs leading-none font-semibold ${TONES[kindToneOf(row.kind)]}`}
158
+ >
159
+ {kindLetterOf(row.kind)}
160
+ </span>
161
+ <span className={`max-w-full shrink-0 truncate font-mono text-code ${row.deprecated ? 'line-through' : ''}`}>
162
+ <MarkedLabel label={row.label} matches={row.matches} />
163
+ </span>
164
+ <span className="min-w-0 grow truncate font-mono text-text-faint">{row.detail}</span>
165
+ <PathText path={row.description} className="max-w-[60%] font-mono text-text-faint" />
166
+ </button>
167
+ );
168
+ })}
169
+ <div aria-hidden style={{ height: (total - last) * ROW_HEIGHT }} />
170
+ </div>
171
+ <div className="flex items-center gap-3 border-t border-border px-2 py-1 text-xs whitespace-nowrap text-text-faint">
172
+ <span>↵ {t('language.completion.insert')}</span>
173
+ <span>⇥ {t('language.completion.replace')}</span>
174
+ <span>^Space {t('language.completion.details')}</span>
175
+ <span className="ml-auto">{view.server}</span>
176
+ </div>
177
+ </div>
178
+ {view.detailsOpen && view.docs !== null && (
179
+ <div
180
+ ref={docs}
181
+ className={`${CARD} flex w-[360px] flex-col divide-y divide-border overflow-y-auto select-text`}
182
+ style={{ visibility: 'hidden' }}
183
+ onPointerDown={(event) => event.preventDefault()}
184
+ >
185
+ {view.docs.source !== '' && (
186
+ <div className="px-3 py-2">
187
+ <SourceLine text={view.docs.source} language={view.highlightLanguage} />
188
+ </div>
189
+ )}
190
+ <SymbolSections text={view.docs.text} />
191
+ </div>
192
+ )}
193
+ </>,
194
+ document.body
195
+ );
196
+ }
@@ -0,0 +1,67 @@
1
+ import { Fragment, useEffect, useRef, type ReactNode } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ContextMenu, Kbd } from '@adecore/ui';
4
+ import type { KeymapId } from '@adecore/editor/keymap';
5
+ import type { EditorLanguage } from './editor-language.ts';
6
+ import type { MenuView } from './popups.ts';
7
+
8
+ export function EditorContextMenu({ language, view, children }: { language: EditorLanguage; view: MenuView; children?: ReactNode }) {
9
+ const { t } = useTranslation('editor');
10
+ const trigger = useRef<HTMLDivElement>(null);
11
+ useEffect(() => {
12
+ trigger.current?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: view.x, clientY: view.y }));
13
+ }, [view.x, view.y]);
14
+ const { service } = language.project;
15
+ const rows: { id: KeymapId; method: string; run(): void }[] = [
16
+ { id: 'goToDefinition', method: 'textDocument/definition', run: () => void language.navigation.go('definition') },
17
+ { id: 'goToTypeDefinition', method: 'textDocument/typeDefinition', run: () => void language.navigation.go('typeDefinition') },
18
+ { id: 'goToImplementation', method: 'textDocument/implementation', run: () => void language.navigation.go('implementation') },
19
+ { id: 'peekReferences', method: 'textDocument/references', run: () => void language.peek.open() },
20
+ { id: 'renameSymbol', method: 'textDocument/rename', run: () => void language.rename.start() },
21
+ { id: 'codeActions', method: 'textDocument/codeAction', run: () => void language.codeActions.open() }
22
+ ];
23
+ return (
24
+ <ContextMenu.Root onOpenChange={(open) => !open && language.contextMenu.close()}>
25
+ <ContextMenu.Trigger ref={trigger} className="fixed top-0 left-0 h-0 w-0" aria-hidden />
26
+ <ContextMenu.Popup>
27
+ {rows
28
+ .filter((row) => service.supports(row.method, language.uri))
29
+ .map((row) => (
30
+ <ContextMenu.Item key={row.id} onClick={row.run}>
31
+ {t(`commands.${row.id}`)}
32
+ {language.shortcuts[row.id] !== null && <Kbd shortcut={language.shortcuts[row.id]!} />}
33
+ </ContextMenu.Item>
34
+ ))}
35
+ {view.refactors.length > 0 && (
36
+ <ContextMenu.SubmenuRoot>
37
+ <ContextMenu.SubmenuTrigger>{t('language.context.refactor')}</ContextMenu.SubmenuTrigger>
38
+ <ContextMenu.Popup>
39
+ {view.refactors.map((entry, index) => (
40
+ <Fragment key={entry.id}>
41
+ {index > 0 && view.refactors[index - 1]!.group !== entry.group && <ContextMenu.Separator />}
42
+ <ContextMenu.Item onClick={() => void language.codeActions.apply(entry)}>{entry.action.title}</ContextMenu.Item>
43
+ </Fragment>
44
+ ))}
45
+ </ContextMenu.Popup>
46
+ </ContextMenu.SubmenuRoot>
47
+ )}
48
+ {children}
49
+ <ContextMenu.Separator />
50
+ {(['cut', 'copy', 'paste'] as const).map((action) => (
51
+ <ContextMenu.Item key={action} onClick={() => language.contextMenu.clipboard(action)}>
52
+ {t(`language.context.${action}`)}
53
+ </ContextMenu.Item>
54
+ ))}
55
+ {service.supports('textDocument/formatting', language.uri) && (
56
+ <>
57
+ <ContextMenu.Separator />
58
+ <ContextMenu.Item onClick={() => void language.codeActions.formatDocument()}>
59
+ {t('commands.formatDocument')}
60
+ {language.shortcuts.formatDocument !== null && <Kbd shortcut={language.shortcuts.formatDocument} />}
61
+ </ContextMenu.Item>
62
+ </>
63
+ )}
64
+ </ContextMenu.Popup>
65
+ </ContextMenu.Root>
66
+ );
67
+ }
@@ -0,0 +1,47 @@
1
+ import { createHolder } from './holder.ts';
2
+ import { useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode, type Ref } from 'react';
3
+ import type { Editor, EditorEngine, EditorOptions } from '@adecore/editor';
4
+ import { EditorLanguage } from './editor-language.ts';
5
+ import { LanguagePopups } from './LanguagePopups.tsx';
6
+ import type { ProjectLanguage } from './project-language.ts';
7
+
8
+ export interface EditorViewProps {
9
+ engine: EditorEngine;
10
+ options: EditorOptions;
11
+ project?: ProjectLanguage;
12
+ uri?: string;
13
+ languageId?: string;
14
+ className?: string;
15
+ ref?: Ref<HTMLDivElement>;
16
+ onMount?(editor: Editor, language: EditorLanguage | null): void | (() => void);
17
+ /* Rows of the app in the context menu, between the refactorings and the clipboard rows. */
18
+ contextMenuItems?(language: EditorLanguage): ReactNode;
19
+ }
20
+
21
+ export function EditorView({ engine, options, project, uri, languageId, className, ref, onMount, contextMenuItems }: EditorViewProps) {
22
+ const element = useRef<HTMLDivElement>(null);
23
+ const holder = useMemo(() => createHolder<EditorLanguage>(), []);
24
+ const language = useSyncExternalStore(holder.subscribe, holder.get, holder.get);
25
+ useLayoutEffect(() => {
26
+ const host = element.current;
27
+ if (host === null) {
28
+ return;
29
+ }
30
+ const editor = engine.mount(host, options);
31
+ const attached = project === undefined || uri === undefined || languageId === undefined ? null : new EditorLanguage(project, editor, uri, languageId);
32
+ holder.set(attached);
33
+ const stop = onMount?.(editor, attached);
34
+ return () => {
35
+ holder.set(null);
36
+ stop?.();
37
+ attached?.dispose();
38
+ editor.dispose();
39
+ };
40
+ }, [engine, options, project, uri, languageId, holder, onMount]);
41
+ return (
42
+ <div className={className} ref={ref} style={{ position: 'relative', minHeight: 0 }}>
43
+ <div ref={element} style={{ position: 'absolute', inset: 0 }} />
44
+ {language !== null && <LanguagePopups language={language} contextMenuItems={contextMenuItems} />}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,89 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button } from '@adecore/ui';
4
+ import { formatNumber } from '@adecore/ui/format';
5
+ import type { Editor, EditorFindState } from '@adecore/editor';
6
+ import { compileFind, EMPTY_FIND_QUERY, type FindQuery } from './find-query.ts';
7
+
8
+ export function FindReplace({ editor, className, onClose }: { editor: Editor; className?: string; onClose?(): void }) {
9
+ const { t } = useTranslation('editor');
10
+ const [query, setQuery] = useState<FindQuery>(EMPTY_FIND_QUERY);
11
+ const [replacement, setReplacement] = useState('');
12
+ const [preserveCase, setPreserveCase] = useState(false);
13
+ const [state, setState] = useState<EditorFindState>({ count: 0, current: null });
14
+ const compiled = useMemo(() => compileFind(query), [query]);
15
+ useEffect(() => editor.onFind(setState), [editor]);
16
+ useEffect(() => {
17
+ editor.find(compiled.kind === 'pattern' ? query : null);
18
+ }, [editor, query, compiled]);
19
+ useEffect(() => {
20
+ editor.setReplacePreview(compiled.kind === 'pattern' ? replacement : null, { preserveCase });
21
+ }, [editor, compiled, replacement, preserveCase]);
22
+ useEffect(
23
+ () => () => {
24
+ editor.endFind();
25
+ editor.setReplacePreview(null);
26
+ },
27
+ [editor]
28
+ );
29
+ const label =
30
+ compiled.kind === 'invalid'
31
+ ? t('find.invalid')
32
+ : state.noSelection
33
+ ? t('find.noSelection')
34
+ : t('find.count', { total: formatNumber(state.count), current: state.current === null ? '0' : formatNumber(state.current + 1) });
35
+ return (
36
+ <div className={`adecore-editor-find ${className ?? ''}`} role="search" aria-label={t('find.label')}>
37
+ <div className="adecore-editor-find-row">
38
+ <input
39
+ type="text"
40
+ aria-label={t('find.label')}
41
+ value={query.text}
42
+ onChange={(event) => setQuery({ ...query, text: event.target.value })}
43
+ onKeyDown={(event) => {
44
+ if (event.nativeEvent.isComposing) {
45
+ return;
46
+ }
47
+ if (event.key === 'Enter') {
48
+ event.preventDefault();
49
+ editor.findStep(event.shiftKey ? -1 : 1);
50
+ }
51
+ if (event.key === 'Escape') {
52
+ event.preventDefault();
53
+ onClose?.();
54
+ editor.focus();
55
+ }
56
+ }}
57
+ />
58
+ <output aria-live="polite">{label}</output>
59
+ <Button size="xs" onClick={() => editor.findStep(-1)}>
60
+ {t('find.previous')}
61
+ </Button>
62
+ <Button size="xs" onClick={() => editor.findStep(1)}>
63
+ {t('find.next')}
64
+ </Button>
65
+ </div>
66
+ <div className="adecore-editor-find-row">
67
+ {(['caseSensitive', 'wholeWord', 'regex', 'inSelection'] as const).map((option) => (
68
+ <label key={option}>
69
+ <input type="checkbox" checked={query[option] === true} onChange={(event) => setQuery({ ...query, [option]: event.target.checked })} />
70
+ {t(`find.${option}`)}
71
+ </label>
72
+ ))}
73
+ </div>
74
+ <div className="adecore-editor-find-row">
75
+ <input type="text" aria-label={t('find.replace.label')} value={replacement} onChange={(event) => setReplacement(event.target.value)} />
76
+ <label>
77
+ <input type="checkbox" checked={preserveCase} onChange={(event) => setPreserveCase(event.target.checked)} />
78
+ {t('find.replace.preserveCase')}
79
+ </label>
80
+ <Button size="xs" onClick={() => editor.replace(replacement, { preserveCase })}>
81
+ {t('find.replace.one')}
82
+ </Button>
83
+ <Button size="xs" onClick={() => editor.replaceAll(replacement, { preserveCase })}>
84
+ {t('find.replace.allLabel')}
85
+ </Button>
86
+ </div>
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,121 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { CircleX, Info, TriangleAlert } from 'lucide-react';
3
+ import { fileUriToPath } from '@adecore/lsp';
4
+ import { Button, Icon, Tooltip } from '@adecore/ui';
5
+ import { formatNumber } from '@adecore/ui/format';
6
+ import type { EditorPosition } from '@adecore/editor';
7
+ import { basenameOf } from './paths.ts';
8
+ import type { EditorLanguage } from './editor-language.ts';
9
+ import { codeLabelOf, severityOf, type Problem } from './diagnostics-model.ts';
10
+ import type { HoverInfo } from './popups.ts';
11
+ import { SymbolSections } from './HoverSections.tsx';
12
+
13
+ const SEVERITY_ICONS = { error: CircleX, warning: TriangleAlert, info: Info, hint: Info } as const;
14
+ const SEVERITY_COLORS = { error: 'text-status-error', warning: 'text-status-needs-you', info: 'text-status-running', hint: 'text-text-muted' } as const;
15
+
16
+ function ProblemSection({ problem, language }: { problem: Problem; language: EditorLanguage }) {
17
+ const { t } = useTranslation('editor');
18
+ const { diagnostic } = problem;
19
+ const severity = severityOf(diagnostic);
20
+ const label =
21
+ diagnostic.source === undefined ? [codeLabelOf(diagnostic), problem.server].filter((part) => part !== '').join(' · ') : codeLabelOf(diagnostic);
22
+ const supported = language.codeActions.supported;
23
+ return (
24
+ <div className="flex flex-col gap-1.5 px-3 py-2.5">
25
+ <div className="flex items-start gap-2">
26
+ <Icon icon={SEVERITY_ICONS[severity]} size={14} className={`mt-px shrink-0 ${SEVERITY_COLORS[severity]}`} />
27
+ <div className="min-w-0 text-xs/[18px] break-words whitespace-pre-wrap select-text">{diagnostic.message}</div>
28
+ </div>
29
+ {label !== '' && <div className="pl-[22px] text-xs text-text-muted select-text">{label}</div>}
30
+ {diagnostic.relatedInformation?.map((related, index) => {
31
+ const path = fileUriToPath(related.location.uri);
32
+ return (
33
+ <div key={index} className="pl-[22px] text-xs text-text-muted">
34
+ {path !== null && (
35
+ <button type="button" className="font-mono text-accent hover:underline" onClick={() => language.goTo(related.location)}>
36
+ {basenameOf(path)}:{formatNumber(related.location.range.start.line + 1)}
37
+ </button>
38
+ )}{' '}
39
+ {related.message}
40
+ </div>
41
+ );
42
+ })}
43
+ <div className="pt-1 pl-[22px]">
44
+ <Tooltip label={t(supported ? 'language.hover.quickFix' : 'language.hover.quickFixNone')}>
45
+ <Button
46
+ size="xs"
47
+ variant="secondary"
48
+ aria-disabled={!supported}
49
+ onClick={() => {
50
+ if (supported) {
51
+ language.hover.hide();
52
+ void language.codeActions.quickFixFor(problem);
53
+ }
54
+ }}
55
+ >
56
+ {t('language.hover.quickFix')}
57
+ </Button>
58
+ </Tooltip>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export function HoverCard({
65
+ language,
66
+ problems,
67
+ info,
68
+ anchor,
69
+ position
70
+ }: {
71
+ language: EditorLanguage;
72
+ problems: readonly Problem[];
73
+ info: HoverInfo | null;
74
+ anchor: EditorPosition;
75
+ position: EditorPosition;
76
+ }) {
77
+ const { t } = useTranslation('editor');
78
+ const place = info?.definition === null || info === null ? null : fileUriToPath(info.definition.uri);
79
+ return (
80
+ <div className="flex w-max min-w-[280px] max-w-[min(520px,calc(100vw-16px))] flex-col divide-y divide-border">
81
+ {info !== null && (
82
+ <>
83
+ <SymbolSections
84
+ text={info.text}
85
+ onName={(name, declared) => {
86
+ language.hover.hide();
87
+ void language.navigation.goToName(name, anchor, name === (declared ?? info.word) ? info.definition : null);
88
+ }}
89
+ />
90
+ <div className="flex items-center gap-3 px-3 py-1.5 text-xs">
91
+ {info.definition !== null && (
92
+ <button type="button" className="text-accent hover:underline" onClick={() => language.goTo(info.definition!)}>
93
+ {t('language.hover.definition')}
94
+ </button>
95
+ )}
96
+ {info.references !== null && info.references > 0 && (
97
+ <button
98
+ type="button"
99
+ className="text-accent hover:underline"
100
+ onClick={() => {
101
+ language.hover.hide();
102
+ void language.peek.open(position);
103
+ }}
104
+ >
105
+ {t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) })}
106
+ </button>
107
+ )}
108
+ {place !== null && info.definition !== null && (
109
+ <span className="ml-auto font-mono text-text-faint">
110
+ {basenameOf(place)}:{formatNumber(info.definition.range.start.line + 1)}
111
+ </span>
112
+ )}
113
+ </div>
114
+ </>
115
+ )}
116
+ {problems.map((problem, index) => (
117
+ <ProblemSection key={index} problem={problem} language={language} />
118
+ ))}
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,146 @@
1
+ import { useEditorRendering } from './rendering-context.ts';
2
+ import { useEffect, useState, type MouseEvent } from 'react';
3
+ import { Markdown } from './Markdown.tsx';
4
+ import { Tooltip } from '@adecore/ui';
5
+ import { hoverSectionsOf, markdownParts, type DocTag, type HoverSection, type HoverText } from './hover-content.ts';
6
+ import { BaselineStatus } from './BaselineStatus.tsx';
7
+ import { declaredNameOf, linkTypeNames } from './symbol-links.ts';
8
+
9
+ /* Code as the viewer colors it, plain until the grammar is in so the card never changes size under the pointer by much. */
10
+ export function Signature({ code, language, onName }: { code: string; language: string; onName?: (name: string) => void }) {
11
+ const { theme, highlight } = useEditorRendering();
12
+ const [html, setHtml] = useState<{ key: string; html: string } | null>(null);
13
+ const key = `${language}\0${theme}\0${code}\0${onName !== undefined}`;
14
+ // Only whether there are links goes into the highlighting; the handler is read when a name is pressed.
15
+ const linked = onName !== undefined;
16
+
17
+ useEffect(() => {
18
+ if (highlight === undefined) {
19
+ return;
20
+ }
21
+ let alive = true;
22
+ highlight(code, language, theme)
23
+ .then((result) => alive && setHtml({ key, html: linked ? linkTypeNames(result) : result }))
24
+ .catch(() => undefined);
25
+ return () => {
26
+ alive = false;
27
+ };
28
+ }, [code, language, theme, key, linked, highlight]);
29
+
30
+ const className =
31
+ 'font-mono text-code break-words whitespace-pre-wrap [&_.line]:block [&_pre]:m-0 [&_pre]:bg-transparent! [&_pre]:whitespace-pre-wrap [&_code]:font-mono';
32
+ // One handler for every name, since the names are part of the highlighted HTML and not elements of ours.
33
+ const follow = (event: MouseEvent<HTMLDivElement>): void => {
34
+ const name = (event.target as HTMLElement).closest<HTMLElement>('[data-symbol]')?.dataset.symbol;
35
+ if (name !== undefined && onName !== undefined) {
36
+ onName(name);
37
+ }
38
+ };
39
+ return html?.key === key ? (
40
+ <div className={className} onClick={follow} dangerouslySetInnerHTML={{ __html: html.html }} />
41
+ ) : (
42
+ <div className={className}>{code}</div>
43
+ );
44
+ }
45
+
46
+ /* A docblock's tags as rows: the tag once beside each run of the same tag, so a list of parameters reads as one. */
47
+ export function DocTags({ tags }: { tags: readonly DocTag[] }) {
48
+ return (
49
+ <div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs/[18px] select-text">
50
+ {tags.map((tag, index) => (
51
+ <div key={index} className="contents">
52
+ <span className="font-mono text-text-faint">{tags[index - 1]?.name === tag.name ? '' : `@${tag.name}`}</span>
53
+ <div className="min-w-0 break-words text-text-muted [&_.adecore-editor-markdown]:text-xs [&_code]:bg-transparent! [&_code]:p-0! [&_p]:m-0">
54
+ <Markdown text={tag.markdown} />
55
+ </div>
56
+ </div>
57
+ ))}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ /* One symbol: its qualified name muted, its signature, the prose and examples as compact blocks, and its tags as rows. Names in the signature are links only where `onName` says what a press does. */
63
+ export function SymbolSection({ section, onName }: { section: HoverSection; onName?: (name: string, declared: string | null) => void }) {
64
+ const declared = declaredNameOf(section.signatures[0]?.code ?? '');
65
+ const follow = onName === undefined ? undefined : (name: string): void => onName(name, declared);
66
+ return (
67
+ <div className="flex flex-col gap-1.5 px-3 py-2">
68
+ {section.title !== null && <div className="truncate font-mono text-xs text-text-faint select-text">{section.title}</div>}
69
+ {section.signatures.map((block, index) => (
70
+ <Signature key={index} code={block.code} language={block.language} onName={follow} />
71
+ ))}
72
+ {section.baseline !== null && <BaselineStatus baseline={section.baseline} />}
73
+ {markdownParts(section.markdown).map((part, index) =>
74
+ part.kind === 'code' ? (
75
+ <div key={index} className="rounded-md bg-surface-sunken px-2 py-1.5">
76
+ <Signature code={part.block.code} language={part.block.language} onName={follow} />
77
+ </div>
78
+ ) : (
79
+ <div key={index} className="text-text-muted select-text [&_.adecore-editor-markdown]:text-xs [&_p]:my-1">
80
+ <Markdown text={part.text} />
81
+ </div>
82
+ )
83
+ )}
84
+ {section.tags.length > 0 && <DocTags tags={section.tags} />}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ /* What a server says about a symbol as the sections of a hover card, shared by the hover and the documentation beside the suggestions. */
90
+ export function SymbolSections({ text, onName }: { text: HoverText; onName?: (name: string, declared: string | null) => void }) {
91
+ return (
92
+ <>
93
+ {hoverSectionsOf(text).map((section, index) => (
94
+ <SymbolSection key={index} section={section} onName={onName} />
95
+ ))}
96
+ </>
97
+ );
98
+ }
99
+
100
+ /* PHP highlights only after its opening tag, which is put in front for the grammar and taken off again. */
101
+ function phpWithoutOpenTag(html: string): string {
102
+ return html.replace(/<span[^>]*>&#x3C;\?<\/span><span[^>]*>php<\/span>(<span[^>]*>) /, '$1');
103
+ }
104
+
105
+ /*
106
+ * Where a suggestion comes from, such as `use Raxos\Database\Orm\Attribute\PrimaryKey`, on one line that gives
107
+ * way from its start, so the end of the name stays readable and a word is never cut in two. The full text is in the tooltip.
108
+ */
109
+ export function SourceLine({ text, language }: { text: string; language: string }) {
110
+ const { theme, highlight } = useEditorRendering();
111
+ const [html, setHtml] = useState<{ key: string; html: string } | null>(null);
112
+ const highlighted = language === 'php' && text.startsWith('use ');
113
+ const key = `${theme}\0${text}`;
114
+
115
+ useEffect(() => {
116
+ if (!highlighted) {
117
+ return;
118
+ }
119
+ if (highlight === undefined) {
120
+ return;
121
+ }
122
+ let alive = true;
123
+ highlight(`<?php ${text}`, 'php', theme)
124
+ .then((result) => alive && setHtml({ key, html: phpWithoutOpenTag(result) }))
125
+ .catch(() => undefined);
126
+ return () => {
127
+ alive = false;
128
+ };
129
+ }, [highlighted, text, theme, key, highlight]);
130
+
131
+ const className =
132
+ 'block overflow-hidden text-left font-mono text-code text-ellipsis whitespace-nowrap [direction:rtl] [&_code]:font-mono [&_pre]:m-0 [&_pre]:inline [&_pre]:bg-transparent! [&_pre]:p-0 [&_code]:inline [&_.line]:inline';
133
+ return (
134
+ <Tooltip label={text}>
135
+ {highlighted && html?.key === key ? (
136
+ <div className={className}>
137
+ <bdi dangerouslySetInnerHTML={{ __html: html.html }} />
138
+ </div>
139
+ ) : (
140
+ <div className={`${className} text-text-muted`}>
141
+ <bdi>{text}</bdi>
142
+ </div>
143
+ )}
144
+ </Tooltip>
145
+ );
146
+ }