@adecore/editor-react 0.23.1 → 0.24.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.
- package/dist/AnchoredPopup.d.ts +2 -1
- package/dist/AnchoredPopup.js +2 -2
- package/dist/CodeAuthorsCard.d.ts +4 -0
- package/dist/CodeAuthorsCard.js +8 -4
- package/dist/CompletionPopup.js +7 -3
- package/dist/EditorView.d.ts +8 -3
- package/dist/EditorView.js +79 -9
- package/dist/HoverCard.d.ts +3 -1
- package/dist/HoverCard.js +2 -2
- package/dist/LanguagePopups.d.ts +8 -4
- package/dist/LanguagePopups.js +4 -4
- package/dist/PickPopup.js +2 -1
- package/dist/RenameCard.js +10 -8
- package/dist/RenderedDocs.d.ts +10 -4
- package/dist/RenderedDocs.js +10 -6
- package/dist/call-hierarchy.js +15 -14
- package/dist/code-actions.js +5 -2
- package/dist/completion-model.js +3 -1
- package/dist/completion.d.ts +4 -2
- package/dist/completion.js +49 -27
- package/dist/diagnostics-model.d.ts +1 -0
- package/dist/diagnostics-model.js +1 -1
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +28 -10
- package/dist/diff-overlays.js +2 -2
- package/dist/editor-language.d.ts +3 -1
- package/dist/editor-language.js +10 -0
- package/dist/format-on-type.js +2 -3
- package/dist/highlights.js +3 -2
- package/dist/history.js +3 -2
- package/dist/holder.d.ts +3 -3
- package/dist/holder.js +2 -1
- package/dist/host-types.d.ts +3 -0
- package/dist/hover.d.ts +5 -0
- package/dist/hover.js +13 -5
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/key-priority.d.ts +23 -0
- package/dist/key-priority.js +39 -0
- package/dist/locales/en.json +2 -1
- package/dist/locales/nl.json +2 -1
- package/dist/models.d.ts +2 -2
- package/dist/models.js +2 -2
- package/dist/navigation.js +3 -2
- package/dist/peek.js +3 -2
- package/dist/pick.js +2 -1
- package/dist/popup-focus.d.ts +5 -0
- package/dist/popup-focus.js +12 -0
- package/dist/popups.d.ts +1 -1
- package/dist/project-language.js +5 -4
- package/dist/project-problems.d.ts +10 -1
- package/dist/project-problems.js +34 -10
- package/dist/proposal-model.d.ts +0 -1
- package/dist/proposal-model.js +0 -8
- package/dist/rename-model.d.ts +2 -1
- package/dist/rename-model.js +57 -0
- package/dist/rename.d.ts +1 -0
- package/dist/rename.js +21 -5
- package/dist/rendered-docs.js +3 -2
- package/dist/signature.js +4 -3
- package/dist/snippet-session.d.ts +1 -1
- package/dist/snippet-session.js +8 -2
- package/dist/symbol-picker.js +3 -2
- package/package.json +6 -6
- package/src/AnchoredPopup.tsx +4 -2
- package/src/CodeAuthorsCard.tsx +21 -12
- package/src/CompletionPopup.tsx +11 -1
- package/src/EditorView.tsx +122 -11
- package/src/HoverCard.tsx +6 -1
- package/src/LanguagePopups.tsx +23 -4
- package/src/PickPopup.tsx +9 -1
- package/src/RenameCard.tsx +15 -9
- package/src/RenderedDocs.tsx +30 -8
- package/src/call-hierarchy.ts +15 -14
- package/src/code-actions.ts +5 -2
- package/src/completion-model.ts +3 -1
- package/src/completion.ts +48 -28
- package/src/diagnostics-model.ts +1 -1
- package/src/diagnostics.ts +29 -13
- package/src/diff-overlays.tsx +12 -8
- package/src/editor-language.ts +12 -1
- package/src/format-on-type.ts +2 -3
- package/src/highlights.ts +3 -2
- package/src/history.ts +3 -2
- package/src/holder.ts +5 -4
- package/src/host-types.ts +5 -0
- package/src/hover.ts +19 -5
- package/src/index.ts +6 -2
- package/src/key-priority.ts +49 -0
- package/src/locales/en.json +2 -1
- package/src/locales/nl.json +2 -1
- package/src/models.ts +1 -1
- package/src/navigation.ts +3 -2
- package/src/peek.ts +3 -2
- package/src/pick.ts +2 -1
- package/src/popup-focus.ts +17 -0
- package/src/popups.ts +1 -2
- package/src/project-language.ts +5 -4
- package/src/project-problems.ts +46 -11
- package/src/proposal-model.ts +0 -9
- package/src/rename-model.ts +60 -1
- package/src/rename.ts +21 -5
- package/src/rendered-docs.ts +3 -2
- package/src/signature.ts +4 -3
- package/src/snippet-session.ts +9 -3
- package/src/symbol-picker.ts +3 -2
- package/dist/relative-time.d.ts +0 -1
- package/dist/relative-time.js +0 -9
- 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
|
-
|
|
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
|
|
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;
|
package/dist/rendered-docs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
if (event.key === 'Escape' && this.isOpen && this.language.popups.getState().callHierarchy
|
|
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;
|
package/dist/snippet-session.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/symbol-picker.js
CHANGED
|
@@ -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.
|
|
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.
|
|
3
|
+
"version": "0.24.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",
|
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
"test": "bun test --conditions=source"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@adecore/editor": "0.
|
|
66
|
-
"@adecore/lsp": "0.
|
|
67
|
-
"@adecore/merge": "0.
|
|
65
|
+
"@adecore/editor": "0.24.0",
|
|
66
|
+
"@adecore/lsp": "0.24.0",
|
|
67
|
+
"@adecore/merge": "0.24.0",
|
|
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.
|
|
75
|
+
"@adecore/ui": "^0.24.0",
|
|
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": "
|
|
82
|
+
"@adecore/ui": "0.24.0",
|
|
83
83
|
"@types/bun": "^1.4.2",
|
|
84
84
|
"@types/react": "^19.3.0",
|
|
85
85
|
"@types/react-dom": "^19.3.0",
|
package/src/AnchoredPopup.tsx
CHANGED
|
@@ -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}
|
package/src/CodeAuthorsCard.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
package/src/CompletionPopup.tsx
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/EditorView.tsx
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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({
|
|
59
|
-
|
|
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
|
}
|
package/src/LanguagePopups.tsx
CHANGED
|
@@ -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.
|
|
31
|
+
* whenever the editor scrolls or resizes.
|
|
21
32
|
*/
|
|
22
|
-
export function LanguagePopups({ language, contextMenuItems
|
|
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
|
|
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
|
|
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
|
|
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"
|