@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.
- package/LICENSE +110 -0
- package/README.md +60 -0
- package/dist/AnchoredPopup.d.ts +12 -0
- package/dist/AnchoredPopup.js +27 -0
- package/dist/AttributionCard.d.ts +14 -0
- package/dist/AttributionCard.js +5 -0
- package/dist/BaselineStatus.d.ts +4 -0
- package/dist/BaselineStatus.js +13 -0
- package/dist/ChangeReview.d.ts +12 -0
- package/dist/ChangeReview.js +27 -0
- package/dist/CodeAuthorsCard.d.ts +6 -0
- package/dist/CodeAuthorsCard.js +22 -0
- package/dist/CompletionPopup.d.ts +8 -0
- package/dist/CompletionPopup.js +103 -0
- package/dist/EditorContextMenu.d.ts +8 -0
- package/dist/EditorContextMenu.js +23 -0
- package/dist/EditorView.d.ts +16 -0
- package/dist/EditorView.js +27 -0
- package/dist/FindReplace.d.ts +6 -0
- package/dist/FindReplace.js +44 -0
- package/dist/HoverCard.d.ts +11 -0
- package/dist/HoverCard.js +38 -0
- package/dist/HoverSections.d.ts +21 -0
- package/dist/HoverSections.js +82 -0
- package/dist/LanguagePopups.d.ts +6 -0
- package/dist/LanguagePopups.js +38 -0
- package/dist/Markdown.d.ts +3 -0
- package/dist/Markdown.js +7 -0
- package/dist/PathText.d.ts +4 -0
- package/dist/PathText.js +12 -0
- package/dist/PeekPanel.d.ts +6 -0
- package/dist/PeekPanel.js +111 -0
- package/dist/PickPopup.d.ts +8 -0
- package/dist/PickPopup.js +33 -0
- package/dist/RenameCard.d.ts +9 -0
- package/dist/RenameCard.js +108 -0
- package/dist/SignatureCard.d.ts +4 -0
- package/dist/SignatureCard.js +8 -0
- package/dist/SymbolPicker.d.ts +6 -0
- package/dist/SymbolPicker.js +108 -0
- package/dist/code-actions-model.d.ts +23 -0
- package/dist/code-actions-model.js +114 -0
- package/dist/code-actions.d.ts +30 -0
- package/dist/code-actions.js +326 -0
- package/dist/code-authors.d.ts +18 -0
- package/dist/code-authors.js +79 -0
- package/dist/code-vision-model.d.ts +21 -0
- package/dist/code-vision-model.js +183 -0
- package/dist/code-vision.d.ts +48 -0
- package/dist/code-vision.js +236 -0
- package/dist/completion-call.d.ts +24 -0
- package/dist/completion-call.js +110 -0
- package/dist/completion-model.d.ts +51 -0
- package/dist/completion-model.js +353 -0
- package/dist/completion.d.ts +43 -0
- package/dist/completion.js +463 -0
- package/dist/conflict-model.d.ts +15 -0
- package/dist/conflict-model.js +36 -0
- package/dist/context-menu.d.ts +10 -0
- package/dist/context-menu.js +55 -0
- package/dist/definition-link.d.ts +14 -0
- package/dist/definition-link.js +99 -0
- package/dist/diagnostics-model.d.ts +15 -0
- package/dist/diagnostics-model.js +83 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.js +95 -0
- package/dist/editor-language.d.ts +65 -0
- package/dist/editor-language.js +170 -0
- package/dist/editor-react.css +43 -0
- package/dist/find-query.d.ts +26 -0
- package/dist/find-query.js +51 -0
- package/dist/folding.d.ts +16 -0
- package/dist/folding.js +95 -0
- package/dist/highlight-layers.d.ts +10 -0
- package/dist/highlight-layers.js +30 -0
- package/dist/highlights.d.ts +16 -0
- package/dist/highlights.js +102 -0
- package/dist/history.d.ts +8 -0
- package/dist/history.js +46 -0
- package/dist/holder.d.ts +5 -0
- package/dist/holder.js +19 -0
- package/dist/host-types.d.ts +45 -0
- package/dist/host-types.js +1 -0
- package/dist/hover-content.d.ts +45 -0
- package/dist/hover-content.js +198 -0
- package/dist/hover.d.ts +32 -0
- package/dist/hover.js +260 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +28 -0
- package/dist/inlay-hints.d.ts +13 -0
- package/dist/inlay-hints.js +71 -0
- package/dist/language-ids.d.ts +3 -0
- package/dist/language-ids.js +49 -0
- package/dist/line-edits.d.ts +8 -0
- package/dist/line-edits.js +39 -0
- package/dist/locales/en.json +171 -0
- package/dist/locales/nl.json +171 -0
- package/dist/models.d.ts +20 -0
- package/dist/models.js +20 -0
- package/dist/navigation-history.d.ts +16 -0
- package/dist/navigation-history.js +53 -0
- package/dist/navigation.d.ts +23 -0
- package/dist/navigation.js +155 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +3 -0
- package/dist/peek-model.d.ts +28 -0
- package/dist/peek-model.js +104 -0
- package/dist/peek.d.ts +30 -0
- package/dist/peek.js +215 -0
- package/dist/pick.d.ts +34 -0
- package/dist/pick.js +166 -0
- package/dist/popup-placement.d.ts +23 -0
- package/dist/popup-placement.js +34 -0
- package/dist/popups.d.ts +133 -0
- package/dist/popups.js +14 -0
- package/dist/project-language.d.ts +36 -0
- package/dist/project-language.js +190 -0
- package/dist/project-problems.d.ts +24 -0
- package/dist/project-problems.js +63 -0
- package/dist/proposal-model.d.ts +36 -0
- package/dist/proposal-model.js +197 -0
- package/dist/recent-choices.d.ts +10 -0
- package/dist/recent-choices.js +39 -0
- package/dist/refresher.d.ts +13 -0
- package/dist/refresher.js +50 -0
- package/dist/relative-time.d.ts +1 -0
- package/dist/relative-time.js +9 -0
- package/dist/rename-model.d.ts +25 -0
- package/dist/rename-model.js +93 -0
- package/dist/rename.d.ts +21 -0
- package/dist/rename.js +257 -0
- package/dist/rendering-context.d.ts +6 -0
- package/dist/rendering-context.js +5 -0
- package/dist/rendering.d.ts +6 -0
- package/dist/rendering.js +5 -0
- package/dist/review-group.d.ts +16 -0
- package/dist/review-group.js +54 -0
- package/dist/row-host.d.ts +42 -0
- package/dist/row-host.js +120 -0
- package/dist/selection-ranges.d.ts +7 -0
- package/dist/selection-ranges.js +26 -0
- package/dist/semantic-model.d.ts +4 -0
- package/dist/semantic-model.js +76 -0
- package/dist/semantic-tokens.d.ts +6 -0
- package/dist/semantic-tokens.js +38 -0
- package/dist/shortcut-keys.d.ts +91 -0
- package/dist/shortcut-keys.js +12 -0
- package/dist/signature-model.d.ts +18 -0
- package/dist/signature-model.js +33 -0
- package/dist/signature.d.ts +22 -0
- package/dist/signature.js +150 -0
- package/dist/snippet-session.d.ts +17 -0
- package/dist/snippet-session.js +110 -0
- package/dist/snippet.d.ts +11 -0
- package/dist/snippet.js +134 -0
- package/dist/symbol-links.d.ts +6 -0
- package/dist/symbol-links.js +50 -0
- package/dist/symbol-picker-model.d.ts +44 -0
- package/dist/symbol-picker-model.js +146 -0
- package/dist/symbol-picker.d.ts +12 -0
- package/dist/symbol-picker.js +76 -0
- package/dist/symbols.d.ts +11 -0
- package/dist/symbols.js +95 -0
- package/dist/testing.d.ts +57 -0
- package/dist/testing.js +156 -0
- package/dist/timers.d.ts +14 -0
- package/dist/timers.js +34 -0
- package/dist/use-problem-counts.d.ts +3 -0
- package/dist/use-problem-counts.js +20 -0
- package/dist/view-state.d.ts +21 -0
- package/dist/view-state.js +63 -0
- package/dist/word-diff.d.ts +1 -0
- package/dist/word-diff.js +32 -0
- package/dist/workspace-edit.d.ts +21 -0
- package/dist/workspace-edit.js +147 -0
- package/package.json +74 -2
- package/src/AnchoredPopup.tsx +51 -0
- package/src/AttributionCard.tsx +36 -0
- package/src/BaselineStatus.tsx +20 -0
- package/src/ChangeReview.tsx +43 -0
- package/src/CodeAuthorsCard.tsx +81 -0
- package/src/CompletionPopup.tsx +196 -0
- package/src/EditorContextMenu.tsx +67 -0
- package/src/EditorView.tsx +47 -0
- package/src/FindReplace.tsx +89 -0
- package/src/HoverCard.tsx +121 -0
- package/src/HoverSections.tsx +146 -0
- package/src/LanguagePopups.tsx +65 -0
- package/src/Markdown.tsx +11 -0
- package/src/PathText.tsx +18 -0
- package/src/PeekPanel.tsx +222 -0
- package/src/PickPopup.tsx +102 -0
- package/src/RenameCard.tsx +219 -0
- package/src/SignatureCard.tsx +42 -0
- package/src/SymbolPicker.tsx +219 -0
- package/src/code-actions-model.ts +146 -0
- package/src/code-actions.ts +384 -0
- package/src/code-authors.ts +104 -0
- package/src/code-vision-model.ts +230 -0
- package/src/code-vision.ts +269 -0
- package/src/completion-call.ts +139 -0
- package/src/completion-model.ts +440 -0
- package/src/completion.ts +500 -0
- package/src/conflict-model.ts +56 -0
- package/src/context-menu.ts +63 -0
- package/src/definition-link.ts +106 -0
- package/src/diagnostics-model.ts +102 -0
- package/src/diagnostics.ts +117 -0
- package/src/editor-language.ts +189 -0
- package/src/editor-react.css +43 -0
- package/src/find-query.ts +74 -0
- package/src/folding.ts +110 -0
- package/src/highlight-layers.ts +37 -0
- package/src/highlights.ts +123 -0
- package/src/history.ts +56 -0
- package/src/holder.ts +19 -0
- package/src/host-types.ts +52 -0
- package/src/hover-content.ts +264 -0
- package/src/hover.ts +280 -0
- package/src/index.ts +63 -0
- package/src/inlay-hints.ts +85 -0
- package/src/language-ids.ts +54 -0
- package/src/line-edits.ts +48 -0
- package/src/locales/en.json +171 -0
- package/src/locales/nl.json +171 -0
- package/src/models.ts +157 -0
- package/src/navigation-history.ts +69 -0
- package/src/navigation.ts +168 -0
- package/src/paths.ts +3 -0
- package/src/peek-model.ts +147 -0
- package/src/peek.ts +247 -0
- package/src/pick.ts +197 -0
- package/src/popup-placement.ts +64 -0
- package/src/popups.ts +204 -0
- package/src/project-language.ts +223 -0
- package/src/project-problems.ts +87 -0
- package/src/proposal-model.ts +259 -0
- package/src/recent-choices.ts +48 -0
- package/src/refresher.ts +56 -0
- package/src/relative-time.ts +10 -0
- package/src/rename-model.ts +130 -0
- package/src/rename.ts +290 -0
- package/src/rendering-context.ts +12 -0
- package/src/rendering.tsx +6 -0
- package/src/review-group.ts +70 -0
- package/src/row-host.ts +158 -0
- package/src/selection-ranges.ts +32 -0
- package/src/semantic-model.ts +80 -0
- package/src/semantic-tokens.ts +46 -0
- package/src/shortcut-keys.ts +21 -0
- package/src/signature-model.ts +49 -0
- package/src/signature.ts +164 -0
- package/src/snippet-session.ts +122 -0
- package/src/snippet.ts +147 -0
- package/src/symbol-links.ts +72 -0
- package/src/symbol-picker-model.ts +198 -0
- package/src/symbol-picker.ts +85 -0
- package/src/symbols.ts +110 -0
- package/src/testing.ts +240 -0
- package/src/timers.ts +45 -0
- package/src/use-problem-counts.ts +25 -0
- package/src/view-state.ts +88 -0
- package/src/word-diff.ts +35 -0
- package/src/workspace-edit.ts +194 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { EditorPosition } from '@adecore/editor';
|
|
2
|
+
import { isIdentifierCharacter } from './completion-model.ts';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import { locationsOf } from './hover-content.ts';
|
|
5
|
+
import { wordRangeAt } from './rename-model.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The name under the pointer drawn as a link while Cmd (Ctrl elsewhere) is held and a server knows where it
|
|
9
|
+
* is defined, which is what a Mod+click on it follows. The editor only says where the pointer is, so the
|
|
10
|
+
* key comes from the window.
|
|
11
|
+
*/
|
|
12
|
+
export class DefinitionLinkFeature {
|
|
13
|
+
private readonly language: EditorLanguage;
|
|
14
|
+
private held = false;
|
|
15
|
+
private pointer: EditorPosition | null = null;
|
|
16
|
+
private shownKey = '';
|
|
17
|
+
private drawn = false;
|
|
18
|
+
private request = 0;
|
|
19
|
+
private lookup: AbortController | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(language: EditorLanguage) {
|
|
22
|
+
this.language = language;
|
|
23
|
+
const { editor } = language;
|
|
24
|
+
const offs = [
|
|
25
|
+
editor.onHover((hover) => {
|
|
26
|
+
this.pointer = hover?.position ?? null;
|
|
27
|
+
this.update();
|
|
28
|
+
}),
|
|
29
|
+
editor.onTextChange(() => this.clear()),
|
|
30
|
+
editor.onViewChange(() => this.clear())
|
|
31
|
+
];
|
|
32
|
+
if (typeof window !== 'undefined') {
|
|
33
|
+
const modKey = language.apple ? 'Meta' : 'Control';
|
|
34
|
+
const keys = (event: KeyboardEvent): void => {
|
|
35
|
+
if (event.key === modKey) {
|
|
36
|
+
this.setModHeld(event.type === 'keydown');
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const lost = (): void => this.setModHeld(false);
|
|
40
|
+
window.addEventListener('keydown', keys);
|
|
41
|
+
window.addEventListener('keyup', keys);
|
|
42
|
+
window.addEventListener('blur', lost);
|
|
43
|
+
offs.push(() => {
|
|
44
|
+
window.removeEventListener('keydown', keys);
|
|
45
|
+
window.removeEventListener('keyup', keys);
|
|
46
|
+
window.removeEventListener('blur', lost);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
language.onDispose(() => {
|
|
50
|
+
for (const off of offs) {
|
|
51
|
+
off();
|
|
52
|
+
}
|
|
53
|
+
this.clear();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setModHeld(held: boolean): void {
|
|
58
|
+
if (held !== this.held) {
|
|
59
|
+
this.held = held;
|
|
60
|
+
this.update();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private clear(): void {
|
|
65
|
+
this.request++;
|
|
66
|
+
this.lookup?.abort();
|
|
67
|
+
this.shownKey = '';
|
|
68
|
+
if (this.drawn) {
|
|
69
|
+
this.drawn = false;
|
|
70
|
+
this.language.editor.setLink(null);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private update(): void {
|
|
75
|
+
const { pointer } = this;
|
|
76
|
+
if (!this.held || pointer === null || !this.language.project.service.supports('textDocument/definition', this.language.uri)) {
|
|
77
|
+
this.clear();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const { editor, project, uri } = this.language;
|
|
81
|
+
const line = editor.textInRange({ start: { line: pointer.line, character: 0 }, end: { line: pointer.line, character: Number.MAX_SAFE_INTEGER } });
|
|
82
|
+
const word = isIdentifierCharacter(line[pointer.character] ?? '') ? wordRangeAt(line, pointer) : null;
|
|
83
|
+
if (word === null) {
|
|
84
|
+
this.clear();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const key = `${word.start.line}:${word.start.character}`;
|
|
88
|
+
if (key === this.shownKey) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.clear();
|
|
92
|
+
this.shownKey = key;
|
|
93
|
+
const request = ++this.request;
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
this.lookup = controller;
|
|
96
|
+
project.service
|
|
97
|
+
.definition(uri, pointer, { signal: controller.signal })
|
|
98
|
+
.then((result) => {
|
|
99
|
+
if (request === this.request && locationsOf(result).length > 0) {
|
|
100
|
+
this.drawn = true;
|
|
101
|
+
editor.setLink(word);
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.catch(() => undefined);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { EditorContentChange, EditorMarker, EditorMarkerSeverity, EditorPosition, EditorRange } from '@adecore/editor';
|
|
2
|
+
import type { Diagnostic } from '@adecore/lsp';
|
|
3
|
+
|
|
4
|
+
/* A diagnostic and the language server process that reported it. */
|
|
5
|
+
export interface Problem {
|
|
6
|
+
readonly diagnostic: Diagnostic;
|
|
7
|
+
readonly server: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SEVERITIES: Record<number, EditorMarkerSeverity> = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' };
|
|
11
|
+
const UNNECESSARY = 1;
|
|
12
|
+
const DEPRECATED = 2;
|
|
13
|
+
/* The scroll track's tip is a line or two, so a long message is cut where it stops being a tip. */
|
|
14
|
+
const TICK_MESSAGE_LENGTH = 300;
|
|
15
|
+
|
|
16
|
+
/* A report that names no severity is an error, which is the safer side to be wrong on. */
|
|
17
|
+
export function severityOf(diagnostic: Diagnostic): EditorMarkerSeverity {
|
|
18
|
+
return SEVERITIES[diagnostic.severity ?? 1] ?? 'error';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function markerOf(diagnostic: Diagnostic): EditorMarker {
|
|
22
|
+
return {
|
|
23
|
+
range: diagnostic.range,
|
|
24
|
+
severity: severityOf(diagnostic),
|
|
25
|
+
unnecessary: diagnostic.tags?.includes(UNNECESSARY) === true,
|
|
26
|
+
deprecated: diagnostic.tags?.includes(DEPRECATED) === true,
|
|
27
|
+
message: diagnostic.message.length > TICK_MESSAGE_LENGTH ? `${diagnostic.message.slice(0, TICK_MESSAGE_LENGTH)}…` : diagnostic.message
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function comparePositions(left: EditorPosition, right: EditorPosition): number {
|
|
32
|
+
return left.line === right.line ? left.character - right.character : left.line - right.line;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Where an insertion of `text` at `start` ends, counting a line break of any kind once. */
|
|
36
|
+
function endOfInsertion(start: EditorPosition, text: string): EditorPosition {
|
|
37
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
38
|
+
return lines.length === 1
|
|
39
|
+
? { line: start.line, character: start.character + text.length }
|
|
40
|
+
: { line: start.line + lines.length - 1, character: lines[lines.length - 1]!.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Where a position is once one change is applied to the text it was in. One inside the replaced range lands after the new text. */
|
|
44
|
+
export function shiftPosition(position: EditorPosition, change: EditorContentChange): EditorPosition {
|
|
45
|
+
const { start, end } = change.range;
|
|
46
|
+
if (comparePositions(position, start) < 0) {
|
|
47
|
+
return position;
|
|
48
|
+
}
|
|
49
|
+
const inserted = endOfInsertion(start, change.text);
|
|
50
|
+
if (comparePositions(position, end) < 0) {
|
|
51
|
+
return inserted;
|
|
52
|
+
}
|
|
53
|
+
return position.line === end.line
|
|
54
|
+
? { line: inserted.line, character: inserted.character + (position.character - end.character) }
|
|
55
|
+
: { line: position.line + (inserted.line - end.line), character: position.character };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function shiftRange(range: EditorRange, changes: readonly EditorContentChange[]): EditorRange {
|
|
59
|
+
let start = range.start;
|
|
60
|
+
let end = range.end;
|
|
61
|
+
for (const change of changes) {
|
|
62
|
+
start = shiftPosition(start, change);
|
|
63
|
+
end = shiftPosition(end, change);
|
|
64
|
+
}
|
|
65
|
+
return { start, end };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* A range holds a position from its start up to and including its end, so a caret at either edge of a word is on it. An empty range holds only its own place. */
|
|
69
|
+
export function rangeHolds(range: EditorRange, position: EditorPosition): boolean {
|
|
70
|
+
return comparePositions(range.start, position) <= 0 && comparePositions(position, range.end) <= 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* The problems under a position, worst first. A hint is not a problem, so it is left to the colors and the faded text. */
|
|
74
|
+
export function problemsAt(problems: readonly Problem[], position: EditorPosition): Problem[] {
|
|
75
|
+
const rank = (problem: Problem): number => problem.diagnostic.severity ?? 1;
|
|
76
|
+
return problems
|
|
77
|
+
.filter((problem) => (problem.diagnostic.severity ?? 1) < 4 && rangeHolds(problem.diagnostic.range, position))
|
|
78
|
+
.sort((left, right) => rank(left) - rank(right));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* The start of the first problem after the position, or the last one before it when `direction` is back; it wraps round the document. */
|
|
82
|
+
export function neighborProblem(problems: readonly Problem[], from: EditorPosition, direction: 1 | -1): Problem | null {
|
|
83
|
+
const candidates = problems
|
|
84
|
+
.filter((problem) => (problem.diagnostic.severity ?? 1) < 4)
|
|
85
|
+
.sort((left, right) => comparePositions(left.diagnostic.range.start, right.diagnostic.range.start));
|
|
86
|
+
if (candidates.length === 0) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (direction === 1) {
|
|
90
|
+
return candidates.find((problem) => comparePositions(problem.diagnostic.range.start, from) > 0) ?? candidates[0]!;
|
|
91
|
+
}
|
|
92
|
+
return [...candidates].reverse().find((problem) => comparePositions(problem.diagnostic.range.start, from) < 0) ?? candidates[candidates.length - 1]!;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* `ts(2551)`, or the code alone, or the source alone, whichever the server gave. */
|
|
96
|
+
export function codeLabelOf(diagnostic: Diagnostic): string {
|
|
97
|
+
const code = diagnostic.code === undefined ? '' : String(diagnostic.code);
|
|
98
|
+
if (diagnostic.source !== undefined && code !== '') {
|
|
99
|
+
return `${diagnostic.source}(${code})`;
|
|
100
|
+
}
|
|
101
|
+
return code !== '' ? code : (diagnostic.source ?? '');
|
|
102
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Diagnostic } from '@adecore/lsp';
|
|
2
|
+
import type { EditorContentChange, EditorPosition } from '@adecore/editor';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import { isShortcut } from './shortcut-keys.ts';
|
|
5
|
+
import { comparePositions, markerOf, neighborProblem, problemsAt, shiftRange, type Problem } from './diagnostics-model.ts';
|
|
6
|
+
|
|
7
|
+
export interface ProblemCounts {
|
|
8
|
+
readonly error: number;
|
|
9
|
+
readonly warning: number;
|
|
10
|
+
readonly info: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The problems the servers report for one file. A report from a server replaces the one before it
|
|
15
|
+
* from the same server, the editor draws them, and each one follows its text through edits made
|
|
16
|
+
* before the server reports again, so what is under a squiggle is still what it was about.
|
|
17
|
+
*/
|
|
18
|
+
export class DiagnosticsFeature {
|
|
19
|
+
private readonly language: EditorLanguage;
|
|
20
|
+
private readonly reports = new Map<string, readonly Diagnostic[]>();
|
|
21
|
+
private readonly listeners = new Set<() => void>();
|
|
22
|
+
private list: readonly Problem[] = [];
|
|
23
|
+
|
|
24
|
+
constructor(language: EditorLanguage) {
|
|
25
|
+
this.language = language;
|
|
26
|
+
const { editor, project, uri } = language;
|
|
27
|
+
const reports = project.service.onDiagnostics((report) => {
|
|
28
|
+
if (report.uri === uri) {
|
|
29
|
+
this.reports.set(report.source, report.diagnostics);
|
|
30
|
+
this.rebuild(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const edits = editor.onTextChange((change) => this.shift(change.changes));
|
|
34
|
+
// These keys are the editor's while it has the keyboard; elsewhere the menu's commands answer.
|
|
35
|
+
const keys = editor.onKeyDown((event) => {
|
|
36
|
+
const next = isShortcut(this.language.shortcuts.nextProblem, event);
|
|
37
|
+
if (!next && !isShortcut(this.language.shortcuts.previousProblem, event)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
this.step(next ? 1 : -1);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
language.onDispose(() => {
|
|
44
|
+
keys();
|
|
45
|
+
reports.dispose();
|
|
46
|
+
edits();
|
|
47
|
+
this.listeners.clear();
|
|
48
|
+
editor.setMarkers([]);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get problems(): readonly Problem[] {
|
|
53
|
+
return this.list;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
counts(): ProblemCounts {
|
|
57
|
+
const count = (severity: number): number => this.list.filter((problem) => (problem.diagnostic.severity ?? 1) === severity).length;
|
|
58
|
+
return { error: count(1), warning: count(2), info: count(3) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
at(position: EditorPosition): Problem[] {
|
|
62
|
+
return problemsAt(this.list, position);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Puts the caret on the next problem, or the one before; false when there is none. */
|
|
66
|
+
step(direction: 1 | -1): boolean {
|
|
67
|
+
const { editor } = this.language;
|
|
68
|
+
const target = neighborProblem(this.list, editor.getCaret(), direction);
|
|
69
|
+
if (target === null) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
editor.setCaret(target.diagnostic.range.start, comparePositions(target.diagnostic.range.start, editor.getCaret()) > 0 ? 'centerDown' : 'centerUp');
|
|
73
|
+
editor.focus();
|
|
74
|
+
this.language.hover.showProblemsAt(editor.getCaret());
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Puts the caret on the first problem of the file. */
|
|
79
|
+
goToFirst(): boolean {
|
|
80
|
+
const { editor } = this.language;
|
|
81
|
+
const target = neighborProblem(this.list, { line: -1, character: 0 }, 1);
|
|
82
|
+
if (target === null) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
editor.setCaret(target.diagnostic.range.start, 'center');
|
|
86
|
+
editor.focus();
|
|
87
|
+
this.language.hover.showProblemsAt(editor.getCaret());
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onChange(listener: () => void): () => void {
|
|
92
|
+
this.listeners.add(listener);
|
|
93
|
+
return () => {
|
|
94
|
+
this.listeners.delete(listener);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private shift(changes: readonly EditorContentChange[]): void {
|
|
99
|
+
for (const [server, diagnostics] of this.reports) {
|
|
100
|
+
this.reports.set(
|
|
101
|
+
server,
|
|
102
|
+
diagnostics.map((diagnostic) => ({ ...diagnostic, range: shiftRange(diagnostic.range, changes) }))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
this.rebuild(false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private rebuild(draw: boolean): void {
|
|
109
|
+
this.list = [...this.reports].flatMap(([server, diagnostics]) => diagnostics.map((diagnostic): Problem => ({ diagnostic, server })));
|
|
110
|
+
if (draw) {
|
|
111
|
+
this.language.editor.setMarkers(this.list.map((problem) => markerOf(problem.diagnostic)));
|
|
112
|
+
}
|
|
113
|
+
for (const listener of [...this.listeners]) {
|
|
114
|
+
listener();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { KEYMAP, type Keymap } from '@adecore/editor/keymap';
|
|
2
|
+
import { shortcutsOf } from './shortcut-keys.ts';
|
|
3
|
+
import type { Editor, EditorPosition } from '@adecore/editor';
|
|
4
|
+
import type { Location } from '@adecore/lsp';
|
|
5
|
+
import { CodeActionsFeature } from './code-actions.ts';
|
|
6
|
+
import { CodeVisionFeature } from './code-vision.ts';
|
|
7
|
+
import { CompletionFeature } from './completion.ts';
|
|
8
|
+
import { ContextMenuFeature } from './context-menu.ts';
|
|
9
|
+
import { DefinitionLinkFeature } from './definition-link.ts';
|
|
10
|
+
import { DiagnosticsFeature } from './diagnostics.ts';
|
|
11
|
+
import { FoldingFeature } from './folding.ts';
|
|
12
|
+
import { HighlightsFeature } from './highlights.ts';
|
|
13
|
+
import { HistoryFeature } from './history.ts';
|
|
14
|
+
import { HoverFeature } from './hover.ts';
|
|
15
|
+
import { NavigationFeature, locationRow } from './navigation.ts';
|
|
16
|
+
import { PeekFeature } from './peek.ts';
|
|
17
|
+
import { PickFeature } from './pick.ts';
|
|
18
|
+
import { SymbolPickerFeature } from './symbol-picker.ts';
|
|
19
|
+
import { RenameFeature } from './rename.ts';
|
|
20
|
+
import { createPopupStore } from './popups.ts';
|
|
21
|
+
import { realTimers, type Timers } from './timers.ts';
|
|
22
|
+
import { InlayHintsFeature } from './inlay-hints.ts';
|
|
23
|
+
import { SelectionRangesFeature } from './selection-ranges.ts';
|
|
24
|
+
import { SemanticTokensFeature } from './semantic-tokens.ts';
|
|
25
|
+
import { SignatureFeature } from './signature.ts';
|
|
26
|
+
import { SnippetFeature } from './snippet-session.ts';
|
|
27
|
+
import { SymbolsFeature } from './symbols.ts';
|
|
28
|
+
import type { Place } from './navigation-history.ts';
|
|
29
|
+
import type { LanguageDocumentHandle, ProjectLanguage } from './project-language.ts';
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* The one place that connects an editor to the language servers: it opens the file's document for
|
|
33
|
+
* the editor and lets go of it again. Every language feature of the editor hangs off this object.
|
|
34
|
+
*/
|
|
35
|
+
export class EditorLanguage {
|
|
36
|
+
readonly editor: Editor;
|
|
37
|
+
readonly apple: boolean;
|
|
38
|
+
readonly keymap: Keymap;
|
|
39
|
+
readonly shortcuts: ReturnType<typeof shortcutsOf>;
|
|
40
|
+
readonly project: ProjectLanguage;
|
|
41
|
+
readonly document: LanguageDocumentHandle;
|
|
42
|
+
readonly languageId: string;
|
|
43
|
+
/* What the features of this editor have open over it: the hover card, the suggestions, the signature. */
|
|
44
|
+
readonly popups = createPopupStore();
|
|
45
|
+
readonly diagnostics: DiagnosticsFeature;
|
|
46
|
+
readonly hover: HoverFeature;
|
|
47
|
+
readonly completion: CompletionFeature;
|
|
48
|
+
readonly signature: SignatureFeature;
|
|
49
|
+
readonly snippets: SnippetFeature;
|
|
50
|
+
readonly highlights: HighlightsFeature;
|
|
51
|
+
readonly pick: PickFeature;
|
|
52
|
+
readonly codeActions: CodeActionsFeature;
|
|
53
|
+
readonly rename: RenameFeature;
|
|
54
|
+
readonly navigation: NavigationFeature;
|
|
55
|
+
readonly peek: PeekFeature;
|
|
56
|
+
readonly symbolPicker: SymbolPickerFeature;
|
|
57
|
+
readonly contextMenu: ContextMenuFeature;
|
|
58
|
+
readonly history: HistoryFeature;
|
|
59
|
+
readonly definitionLink: DefinitionLinkFeature;
|
|
60
|
+
readonly symbols: SymbolsFeature;
|
|
61
|
+
readonly codeVision: CodeVisionFeature;
|
|
62
|
+
private readonly disposers: Array<() => void> = [];
|
|
63
|
+
private disposed = false;
|
|
64
|
+
|
|
65
|
+
constructor(project: ProjectLanguage, editor: Editor, uri: string, languageId: string, timers: Timers = realTimers) {
|
|
66
|
+
this.editor = editor;
|
|
67
|
+
this.apple = project.host.apple ?? false;
|
|
68
|
+
this.keymap = project.host.keymap ?? KEYMAP;
|
|
69
|
+
this.shortcuts = shortcutsOf(this.keymap, this.apple);
|
|
70
|
+
this.project = project;
|
|
71
|
+
this.languageId = languageId;
|
|
72
|
+
this.document = project.acquire(uri, languageId, editor);
|
|
73
|
+
this.pick = new PickFeature(this, timers);
|
|
74
|
+
this.diagnostics = new DiagnosticsFeature(this);
|
|
75
|
+
this.hover = new HoverFeature(this, timers);
|
|
76
|
+
this.completion = new CompletionFeature(this, timers);
|
|
77
|
+
this.snippets = new SnippetFeature(this);
|
|
78
|
+
this.signature = new SignatureFeature(this, timers);
|
|
79
|
+
this.highlights = new HighlightsFeature(this, timers);
|
|
80
|
+
this.symbols = new SymbolsFeature(this, timers);
|
|
81
|
+
new SelectionRangesFeature(this);
|
|
82
|
+
new SemanticTokensFeature(this, timers);
|
|
83
|
+
new InlayHintsFeature(this, timers);
|
|
84
|
+
this.codeActions = new CodeActionsFeature(this, timers);
|
|
85
|
+
this.rename = new RenameFeature(this);
|
|
86
|
+
this.navigation = new NavigationFeature(this);
|
|
87
|
+
this.peek = new PeekFeature(this);
|
|
88
|
+
this.symbolPicker = new SymbolPickerFeature(this);
|
|
89
|
+
this.contextMenu = new ContextMenuFeature(this);
|
|
90
|
+
this.history = new HistoryFeature(this);
|
|
91
|
+
this.definitionLink = new DefinitionLinkFeature(this);
|
|
92
|
+
this.codeVision = new CodeVisionFeature(this, (listener) => this.symbols.onResult(listener), timers);
|
|
93
|
+
new FoldingFeature(this, (listener) => this.symbols.onResult(listener), timers);
|
|
94
|
+
this.placeOpenedCaret(editor.getCaret());
|
|
95
|
+
this.onDispose(editor.onCaret((position) => this.placeOpenedCaret(position)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* A file opened from another one lands on the line; this puts the caret on the column the jump meant. */
|
|
99
|
+
private placeOpenedCaret(position: EditorPosition): void {
|
|
100
|
+
const column = this.project.takeCaret(this.uri, position.line);
|
|
101
|
+
if (column !== null && column.character !== position.character) {
|
|
102
|
+
this.editor.setCaret(column);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.project.noteCaret({ uri: this.uri, position });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get uri(): string {
|
|
109
|
+
return this.document.uri;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get isDisposed(): boolean {
|
|
113
|
+
return this.disposed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Where the caret is, as a place the history can come back to. */
|
|
117
|
+
get place(): Place {
|
|
118
|
+
return { uri: this.uri, position: this.editor.getCaret() };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Takes the caret to a place the servers named, in this file or in another one that opens beside it, and remembers where it was. */
|
|
122
|
+
goTo(location: Location): void {
|
|
123
|
+
this.project.history.record(this.place);
|
|
124
|
+
this.visit({ uri: location.uri, position: location.range.start });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Takes the caret to a position of this file and remembers where it was. */
|
|
128
|
+
jump(position: EditorPosition): void {
|
|
129
|
+
this.goTo({ uri: this.uri, range: { start: position, end: position } });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Goes to a place without touching the history, which is how Back and Forward move. */
|
|
133
|
+
visit(place: Place): void {
|
|
134
|
+
if (place.uri === this.uri) {
|
|
135
|
+
this.editor.setCaret(place.position, 'center');
|
|
136
|
+
this.editor.focus();
|
|
137
|
+
} else {
|
|
138
|
+
this.project.openPlace(place);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Lists places under a position to choose from; choosing one goes there. */
|
|
143
|
+
locations(places: readonly Location[], anchor: EditorPosition, title: string): void {
|
|
144
|
+
const rows = places.map((place, index) => ({ id: String(index), ...locationRow(place, this.project.pathOfLocation(place.uri)) }));
|
|
145
|
+
this.pick.open({ anchor, title, groups: [{ title: null, rows }], accept: (id) => this.goTo(places[Number(id)]!) });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
* Runs `listener` once the document is open and again whenever the service says what it can ask for
|
|
150
|
+
* this document changed. A service that is ready before the document opens never says so, so the
|
|
151
|
+
* opening counts as the first change.
|
|
152
|
+
*/
|
|
153
|
+
onProvidersChanged(listener: () => void): () => void {
|
|
154
|
+
let listening = true;
|
|
155
|
+
void this.document.ready.then(
|
|
156
|
+
() => {
|
|
157
|
+
if (listening && !this.disposed) {
|
|
158
|
+
listener();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
() => undefined
|
|
162
|
+
);
|
|
163
|
+
const providers = this.project.service.onProvidersChanged((uri) => {
|
|
164
|
+
if (uri === this.uri) {
|
|
165
|
+
listener();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return () => {
|
|
169
|
+
listening = false;
|
|
170
|
+
providers.dispose();
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* Runs `dispose` when the editor lets go of its document. */
|
|
175
|
+
onDispose(dispose: () => void): void {
|
|
176
|
+
this.disposers.push(dispose);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
dispose(): void {
|
|
180
|
+
if (this.disposed) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.disposed = true;
|
|
184
|
+
for (const dispose of this.disposers.splice(0).reverse()) {
|
|
185
|
+
dispose();
|
|
186
|
+
}
|
|
187
|
+
this.document.release();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.peek-hatch {
|
|
2
|
+
flex-shrink: 0;
|
|
3
|
+
background: repeating-linear-gradient(135deg, transparent 0 6px, var(--editor-hatch) 6px 7px);
|
|
4
|
+
}
|
|
5
|
+
.adecore-editor-markdown {
|
|
6
|
+
overflow-wrap: anywhere;
|
|
7
|
+
white-space: normal;
|
|
8
|
+
}
|
|
9
|
+
.adecore-editor-markdown p {
|
|
10
|
+
margin: 0;
|
|
11
|
+
}
|
|
12
|
+
.adecore-editor-markdown pre {
|
|
13
|
+
overflow: auto;
|
|
14
|
+
font-family: var(--font-mono);
|
|
15
|
+
}
|
|
16
|
+
.adecore-editor-markdown a {
|
|
17
|
+
color: var(--accent);
|
|
18
|
+
text-decoration: underline;
|
|
19
|
+
}
|
|
20
|
+
.adecore-editor-find {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: 8px;
|
|
24
|
+
padding: 8px;
|
|
25
|
+
background: var(--surface-raised);
|
|
26
|
+
color: var(--text);
|
|
27
|
+
}
|
|
28
|
+
.adecore-editor-find-row {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 8px;
|
|
32
|
+
}
|
|
33
|
+
.adecore-editor-find-row input[type='text'] {
|
|
34
|
+
min-width: 0;
|
|
35
|
+
flex: 1;
|
|
36
|
+
}
|
|
37
|
+
.adecore-editor-find :focus-visible {
|
|
38
|
+
outline: 2px solid var(--accent);
|
|
39
|
+
outline-offset: 2px;
|
|
40
|
+
}
|
|
41
|
+
.adecore-editor-find output {
|
|
42
|
+
font-variant-numeric: tabular-nums;
|
|
43
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface FindOptions {
|
|
2
|
+
caseSensitive: boolean;
|
|
3
|
+
wholeWord: boolean;
|
|
4
|
+
regex: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface FindQuery extends FindOptions {
|
|
8
|
+
text: string;
|
|
9
|
+
/* A surface that can search a selection only: just the text selected when this turned on. */
|
|
10
|
+
inSelection?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const EMPTY_FIND_QUERY: FindQuery = { text: '', caseSensitive: false, wholeWord: false, regex: false };
|
|
14
|
+
|
|
15
|
+
export interface TextMatch {
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CompiledFind = { kind: 'empty' } | { kind: 'invalid' } | { kind: 'pattern'; pattern: RegExp };
|
|
21
|
+
|
|
22
|
+
/* Past this a count stops meaning anything to a reader, and marking every one would stall the page. */
|
|
23
|
+
export const MATCH_LIMIT = 10000;
|
|
24
|
+
|
|
25
|
+
// A letter or digit in any script, so a whole word in Dutch ends at the same place as one in English.
|
|
26
|
+
const WORD_CHAR = '[\\p{L}\\p{N}_]';
|
|
27
|
+
|
|
28
|
+
function escapeRegExp(text: string): string {
|
|
29
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* `u` in both modes, since a whole word needs Unicode classes; a pattern that only parses without it counts as invalid. */
|
|
33
|
+
export function compileFind(query: FindQuery): CompiledFind {
|
|
34
|
+
if (query.text === '') {
|
|
35
|
+
return { kind: 'empty' };
|
|
36
|
+
}
|
|
37
|
+
const body = query.regex ? query.text : escapeRegExp(query.text);
|
|
38
|
+
const source = query.wholeWord ? `(?<!${WORD_CHAR})(?:${body})(?!${WORD_CHAR})` : body;
|
|
39
|
+
// Multiline, so `^` and `$` in a pattern mean a line, the way an editor's find reads them.
|
|
40
|
+
const flags = `gmu${query.caseSensitive ? '' : 'i'}`;
|
|
41
|
+
try {
|
|
42
|
+
return { kind: 'pattern', pattern: new RegExp(source, flags) };
|
|
43
|
+
} catch {
|
|
44
|
+
return { kind: 'invalid' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Every match in a text, in order. An empty match (`a*` between two letters) is nothing to show, so it is stepped over. */
|
|
49
|
+
export function matchesIn(text: string, pattern: RegExp, limit: number = MATCH_LIMIT): TextMatch[] {
|
|
50
|
+
const matches: TextMatch[] = [];
|
|
51
|
+
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
52
|
+
let found = global.exec(text);
|
|
53
|
+
while (found !== null && matches.length < limit) {
|
|
54
|
+
if (found[0] === '') {
|
|
55
|
+
// A `u` pattern moves a lastIndex inside a surrogate pair back to its start, so one step would never leave it.
|
|
56
|
+
global.lastIndex = found.index + ((text.codePointAt(found.index) ?? 0) > 0xffff ? 2 : 1);
|
|
57
|
+
} else {
|
|
58
|
+
matches.push({ start: found.index, end: found.index + found[0].length });
|
|
59
|
+
}
|
|
60
|
+
found = global.exec(text);
|
|
61
|
+
}
|
|
62
|
+
return matches;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* The step from one match to the next or the one before, round at either end. */
|
|
66
|
+
export function stepIndex(current: number | null, count: number, direction: 1 | -1): number | null {
|
|
67
|
+
if (count === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (current === null) {
|
|
71
|
+
return direction === 1 ? 0 : count - 1;
|
|
72
|
+
}
|
|
73
|
+
return (current + direction + count) % count;
|
|
74
|
+
}
|