@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,168 @@
|
|
|
1
|
+
import { StaleResultError, fileUriToPath, type Location, type LocationLink, type NavigationResult, type Range } from '@adecore/lsp';
|
|
2
|
+
import type { EditorPosition } from '@adecore/editor';
|
|
3
|
+
import { basenameOf } from './paths.ts';
|
|
4
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
5
|
+
import { rangeHolds } from './diagnostics-model.ts';
|
|
6
|
+
import { placesOfName } from './symbol-links.ts';
|
|
7
|
+
import { isShortcut } from './shortcut-keys.ts';
|
|
8
|
+
|
|
9
|
+
export type NavigationKind = 'definition' | 'declaration' | 'typeDefinition' | 'implementation';
|
|
10
|
+
|
|
11
|
+
const METHODS: Record<NavigationKind, string> = {
|
|
12
|
+
definition: 'textDocument/definition',
|
|
13
|
+
declaration: 'textDocument/declaration',
|
|
14
|
+
typeDefinition: 'textDocument/typeDefinition',
|
|
15
|
+
implementation: 'textDocument/implementation'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const SHORTCUT_KINDS: readonly [NavigationKind, 'goToDefinition' | 'goToTypeDefinition' | 'goToImplementation'][] = [
|
|
19
|
+
['definition', 'goToDefinition'],
|
|
20
|
+
['typeDefinition', 'goToTypeDefinition'],
|
|
21
|
+
['implementation', 'goToImplementation']
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const TOAST_ID = 'language-navigation';
|
|
25
|
+
|
|
26
|
+
/* The places of a result, each once: the two servers of a Vue file may both know the same one. `target` is the whole declaration when the server gave it. */
|
|
27
|
+
export function uniquePlaces(result: NavigationResult): { location: Location; target: Range | null }[] {
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
const list = result === null ? [] : Array.isArray(result) ? (result as (Location | LocationLink)[]) : [result];
|
|
30
|
+
return list
|
|
31
|
+
.map((entry) =>
|
|
32
|
+
'targetUri' in entry
|
|
33
|
+
? { location: { uri: entry.targetUri, range: entry.targetSelectionRange }, target: entry.targetRange as Range | null }
|
|
34
|
+
: { location: entry, target: null }
|
|
35
|
+
)
|
|
36
|
+
.filter(({ location }) => {
|
|
37
|
+
const { start } = location.range;
|
|
38
|
+
const key = `${location.uri}\0${start.line}\0${start.character}`;
|
|
39
|
+
if (seen.has(key)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
seen.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* The places of a result, each once. */
|
|
48
|
+
export function uniqueLocations(result: NavigationResult): Location[] {
|
|
49
|
+
return uniquePlaces(result).map(({ location }) => location);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* Going to where a name is defined, declared, typed or implemented: Mod+click on the name or a shortcut
|
|
54
|
+
* for the caret. One place opens at once, in this file or in the one it lives in, the way a file opens in
|
|
55
|
+
* the host; several are listed under the name to choose from.
|
|
56
|
+
*/
|
|
57
|
+
export class NavigationFeature {
|
|
58
|
+
private readonly language: EditorLanguage;
|
|
59
|
+
|
|
60
|
+
constructor(language: EditorLanguage) {
|
|
61
|
+
this.language = language;
|
|
62
|
+
const { editor } = language;
|
|
63
|
+
const offs = [
|
|
64
|
+
editor.onClick((click) => {
|
|
65
|
+
if (!click.mod || click.alt || click.shift || !this.supports('definition')) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
void this.go('definition', click.position);
|
|
69
|
+
return true;
|
|
70
|
+
}),
|
|
71
|
+
editor.onKeyDown((event) => {
|
|
72
|
+
const found = SHORTCUT_KINDS.find(([, name]) => isShortcut(this.language.shortcuts[name], event));
|
|
73
|
+
if (found === undefined) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
void this.go(found[0]);
|
|
77
|
+
return true;
|
|
78
|
+
})
|
|
79
|
+
];
|
|
80
|
+
language.onDispose(() => {
|
|
81
|
+
for (const off of offs) {
|
|
82
|
+
off();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
supports(kind: NavigationKind): boolean {
|
|
88
|
+
return this.language.project.service.supports(METHODS[kind], this.language.uri);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async go(kind: NavigationKind, position: EditorPosition = this.language.editor.getCaret()): Promise<void> {
|
|
92
|
+
const { project, uri, editor } = this.language;
|
|
93
|
+
if (!this.supports(kind)) {
|
|
94
|
+
this.tell(this.say('unavailable'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let result: NavigationResult;
|
|
98
|
+
try {
|
|
99
|
+
const { service } = project;
|
|
100
|
+
result = await (kind === 'definition'
|
|
101
|
+
? service.definition(uri, position)
|
|
102
|
+
: kind === 'declaration'
|
|
103
|
+
? service.declaration(uri, position)
|
|
104
|
+
: kind === 'typeDefinition'
|
|
105
|
+
? service.typeDefinition(uri, position)
|
|
106
|
+
: service.implementation(uri, position));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!(error instanceof StaleResultError)) {
|
|
109
|
+
this.tell(this.say('failed', { message: error instanceof Error ? error.message : String(error) }));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const places = uniqueLocations(result);
|
|
114
|
+
if (places.length === 0) {
|
|
115
|
+
this.tell(this.say('none'));
|
|
116
|
+
} else if (kind === 'definition' && places.length === 1 && places[0]!.uri === uri && rangeHolds(places[0]!.range, position)) {
|
|
117
|
+
// The name is its own definition, so what a person wants to see is where it is used.
|
|
118
|
+
void this.language.peek.open(position);
|
|
119
|
+
} else if (places.length === 1) {
|
|
120
|
+
this.language.goTo(places[0]!);
|
|
121
|
+
} else {
|
|
122
|
+
this.language.locations(places, position, this.say(`titles.${kind}`));
|
|
123
|
+
}
|
|
124
|
+
editor.focus();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* A name in the signature of a hover, which is a snippet and no document a server can be asked about by position.
|
|
129
|
+
* The symbol the signature declares goes to the definition the hover already knows; any other name is looked
|
|
130
|
+
* up by name among the symbols of the project. One match goes there at once, several are listed, and none says nothing.
|
|
131
|
+
*/
|
|
132
|
+
async goToName(name: string, anchor: EditorPosition, own: Location | null): Promise<void> {
|
|
133
|
+
const { project, uri, editor } = this.language;
|
|
134
|
+
if (own !== null) {
|
|
135
|
+
this.language.goTo(own);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!project.service.supports('workspace/symbol', uri)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const places = placesOfName(await project.service.workspaceSymbols(uri, name), name, editor.getText());
|
|
143
|
+
if (places.length === 1) {
|
|
144
|
+
this.language.goTo(places[0]!);
|
|
145
|
+
} else if (places.length > 1) {
|
|
146
|
+
this.language.locations(places, anchor, this.say('titles.symbol'));
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// A name that cannot be found is a link that does nothing, not an error.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private tell(title: string): void {
|
|
154
|
+
this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private say(key: string, options?: Record<string, unknown>): string {
|
|
158
|
+
return this.language.project.i18n.t(`editor:language.navigation.${key}`, options);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* How a place reads in a list: the file and the line, with the folder it is in. */
|
|
163
|
+
export function locationRow(location: Location, stored: string | null): { label: string; detail: string; path: true } {
|
|
164
|
+
const path = stored ?? fileUriToPath(location.uri) ?? location.uri;
|
|
165
|
+
const name = basenameOf(path);
|
|
166
|
+
const folder = path.slice(0, Math.max(0, path.length - name.length)).replace(/\/$/, '');
|
|
167
|
+
return { label: `${name}:${location.range.start.line + 1}`, detail: folder, path: true };
|
|
168
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Location, Range } from '@adecore/lsp';
|
|
2
|
+
|
|
3
|
+
/* A stretch of one line, in characters from its start. */
|
|
4
|
+
export interface NameRange {
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* One reference in the list: the line it is on, as the file reads there. */
|
|
10
|
+
export interface PeekPlace {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly location: Location;
|
|
13
|
+
/* Zero-based. */
|
|
14
|
+
readonly line: number;
|
|
15
|
+
readonly text: string;
|
|
16
|
+
/* Where the name stands in `text`; null without a line, or when the line is not what the server read. */
|
|
17
|
+
readonly name: NameRange | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PeekFile {
|
|
21
|
+
readonly uri: string;
|
|
22
|
+
readonly places: readonly PeekPlace[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The most files whose text is read for the list; a name used everywhere shows the rest as places without a line. */
|
|
26
|
+
export const PEEK_READ_FILES = 30;
|
|
27
|
+
|
|
28
|
+
/* The part of `line` a range covers, which runs to the end of the line when the range goes on. */
|
|
29
|
+
function columnsOf(range: Range, line: number, length: number): NameRange | null {
|
|
30
|
+
if (range.start.line !== line) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const end = Math.min(length, range.end.line === line ? range.end.character : length);
|
|
34
|
+
return range.start.character < end ? { start: range.start.character, end } : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* The name of a place within its line once the indentation is cut off. */
|
|
38
|
+
function trimmedNameOf(raw: string, range: Range, line: number): NameRange | null {
|
|
39
|
+
const leading = raw.length - raw.trimStart().length;
|
|
40
|
+
const columns = columnsOf(range, line, raw.length);
|
|
41
|
+
const length = raw.trim().length;
|
|
42
|
+
if (columns === null || columns.start < leading || columns.start >= leading + length) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { start: columns.start - leading, end: Math.min(length, columns.end - leading) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* The references grouped by file, the file the peek was opened in first and the others in the order the
|
|
50
|
+
* server gave them, each file's places by line. `textOf` answers null for a file whose text is not at hand.
|
|
51
|
+
*/
|
|
52
|
+
export function peekFilesOf(locations: readonly Location[], current: string, textOf: (uri: string) => string | null): PeekFile[] {
|
|
53
|
+
const order: string[] = [];
|
|
54
|
+
const byFile = new Map<string, Location[]>();
|
|
55
|
+
for (const location of locations) {
|
|
56
|
+
if (!byFile.has(location.uri)) {
|
|
57
|
+
order.push(location.uri);
|
|
58
|
+
byFile.set(location.uri, []);
|
|
59
|
+
}
|
|
60
|
+
byFile.get(location.uri)!.push(location);
|
|
61
|
+
}
|
|
62
|
+
const uris = [...order.filter((uri) => uri === current), ...order.filter((uri) => uri !== current)];
|
|
63
|
+
return uris.map((uri, fileIndex) => {
|
|
64
|
+
const text = textOf(uri);
|
|
65
|
+
const lines = text === null ? null : text.split(/\r\n|\r|\n/);
|
|
66
|
+
const places = [...byFile.get(uri)!]
|
|
67
|
+
.sort((left, right) => left.range.start.line - right.range.start.line || left.range.start.character - right.range.start.character)
|
|
68
|
+
.map((location, index) => {
|
|
69
|
+
const line = location.range.start.line;
|
|
70
|
+
const raw = lines?.[line] ?? '';
|
|
71
|
+
return {
|
|
72
|
+
id: `${fileIndex}:${index}`,
|
|
73
|
+
location,
|
|
74
|
+
line,
|
|
75
|
+
text: raw.trim(),
|
|
76
|
+
name: lines === null ? null : trimmedNameOf(raw, location.range, line)
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
return { uri, places };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* A few lines of a file around a line, to draw as the preview of a reference. */
|
|
84
|
+
export interface PeekSnippet {
|
|
85
|
+
readonly startLine: number;
|
|
86
|
+
readonly text: string;
|
|
87
|
+
/* Zero-based, from the first line of the snippet. */
|
|
88
|
+
readonly active: number;
|
|
89
|
+
/* The name on the active line, in characters of the line as it stands in `text`. */
|
|
90
|
+
readonly name: NameRange | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const BEFORE = 3;
|
|
94
|
+
const AFTER = 8;
|
|
95
|
+
|
|
96
|
+
export function snippetOf(text: string, line: number, range: Range): PeekSnippet {
|
|
97
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
98
|
+
const startLine = Math.max(0, line - BEFORE);
|
|
99
|
+
const end = Math.min(lines.length, line + AFTER + 1);
|
|
100
|
+
return { startLine, text: lines.slice(startLine, end).join('\n'), active: line - startLine, name: columnsOf(range, line, (lines[line] ?? '').length) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFINITION_LINES = 24;
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
* The source of a definition: the lines of the whole declaration when the server gave them, else a stretch
|
|
107
|
+
* from just above the name, cut at a screenful since a class is longer than a peek is tall.
|
|
108
|
+
*/
|
|
109
|
+
export function definitionSnippetOf(text: string, line: number, declaration: Range | null, range: Range): PeekSnippet {
|
|
110
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
111
|
+
const startLine = declaration === null ? Math.max(0, line - 2) : Math.min(declaration.start.line, line);
|
|
112
|
+
const end = Math.min(lines.length - 1, declaration === null ? line + DEFINITION_LINES / 2 : Math.min(declaration.end.line, startLine + DEFINITION_LINES));
|
|
113
|
+
return { startLine, text: lines.slice(startLine, end + 1).join('\n'), active: line - startLine, name: columnsOf(range, line, (lines[line] ?? '').length) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* For each path, the trailing folders that tell it from the other paths with the same file name, as few
|
|
118
|
+
* as it takes; empty for a file whose name is its own.
|
|
119
|
+
*/
|
|
120
|
+
export function distinguishingFolders(paths: readonly string[]): string[] {
|
|
121
|
+
const folders = paths.map((path) => path.split('/').slice(0, -1));
|
|
122
|
+
const names = paths.map((path) => path.slice(path.lastIndexOf('/') + 1));
|
|
123
|
+
return paths.map((path, index) => {
|
|
124
|
+
const same = paths.map((_, other) => other).filter((other) => names[other] === names[index] && paths[other] !== path);
|
|
125
|
+
if (same.length === 0) {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
const rivals = [...new Set(same.map((other) => paths[other]!))].map((other) => folders[paths.indexOf(other)]!);
|
|
129
|
+
const mine = folders[index]!;
|
|
130
|
+
for (let depth = 1; depth <= mine.length; depth++) {
|
|
131
|
+
const tail = mine.slice(-depth).join('/');
|
|
132
|
+
if (rivals.every((rival) => rival.slice(-depth).join('/') !== tail)) {
|
|
133
|
+
return tail;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return mine.join('/');
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* The column a character stands at once tabs are expanded to their stops, which is where a monospace face draws it. */
|
|
141
|
+
export function visualColumnOf(line: string, character: number, tabSize: number): number {
|
|
142
|
+
let column = 0;
|
|
143
|
+
for (let i = 0; i < Math.min(character, line.length); i++) {
|
|
144
|
+
column += line[i] === '\t' ? tabSize - (column % tabSize) : 1;
|
|
145
|
+
}
|
|
146
|
+
return column;
|
|
147
|
+
}
|
package/src/peek.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { StaleResultError, type Location, type Range } from '@adecore/lsp';
|
|
2
|
+
import type { EditorPosition } from '@adecore/editor';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import { uniquePlaces } from './navigation.ts';
|
|
5
|
+
import { PEEK_READ_FILES, definitionSnippetOf, peekFilesOf, snippetOf, type PeekFile } from './peek-model.ts';
|
|
6
|
+
import type { PeekView } from './popups.ts';
|
|
7
|
+
import { isShortcut } from './shortcut-keys.ts';
|
|
8
|
+
|
|
9
|
+
const TOAST_ID = 'language-peek';
|
|
10
|
+
/* The block as the panel draws it: a hatched band, the header, eight lines of code and a hatched band, until the editor has measured it. */
|
|
11
|
+
export const PEEK_HEIGHT = 241;
|
|
12
|
+
|
|
13
|
+
type PeekKind = PeekView['kind'];
|
|
14
|
+
|
|
15
|
+
const KINDS = {
|
|
16
|
+
references: { method: 'textDocument/references', unavailable: 'unavailable', none: 'none' },
|
|
17
|
+
definitions: { method: 'textDocument/definition', unavailable: 'unavailableDefinition', none: 'noDefinition' }
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
function placeKey(location: Location): string {
|
|
21
|
+
return `${location.uri}\0${location.range.start.line}\0${location.range.start.character}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* The references of the name at the caret, between the lines of the file under the line it is on. The
|
|
26
|
+
* editor keeps the keyboard: arrows pick a reference, Enter goes to it and Escape closes the peek; typing
|
|
27
|
+
* closes it too. A click on a place shows its code and a second one goes there.
|
|
28
|
+
*/
|
|
29
|
+
export class PeekFeature {
|
|
30
|
+
private readonly language: EditorLanguage;
|
|
31
|
+
private kind: PeekKind = 'references';
|
|
32
|
+
/* The whole declaration of each place of a definition peek, where the server gave it. */
|
|
33
|
+
private targets = new Map<string, Range>();
|
|
34
|
+
private texts = new Map<string, string>();
|
|
35
|
+
private places = new Map<string, Location>();
|
|
36
|
+
private order: string[] = [];
|
|
37
|
+
private line = 0;
|
|
38
|
+
private token = 0;
|
|
39
|
+
|
|
40
|
+
constructor(language: EditorLanguage) {
|
|
41
|
+
this.language = language;
|
|
42
|
+
const { editor } = language;
|
|
43
|
+
const offs = [
|
|
44
|
+
editor.onKeyDown((event) => {
|
|
45
|
+
if (isShortcut(this.language.shortcuts.peekReferences, event)) {
|
|
46
|
+
void this.open();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (isShortcut(this.language.shortcuts.peekDefinition, event)) {
|
|
50
|
+
void this.openDefinition();
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return this.key(event);
|
|
54
|
+
}),
|
|
55
|
+
editor.onTextChange(() => this.close())
|
|
56
|
+
];
|
|
57
|
+
language.onDispose(() => {
|
|
58
|
+
for (const off of offs) {
|
|
59
|
+
off();
|
|
60
|
+
}
|
|
61
|
+
this.close();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get isOpen(): boolean {
|
|
66
|
+
return this.language.popups.getState().peek !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async open(position: EditorPosition = this.language.editor.getCaret(), kind: PeekKind = 'references'): Promise<void> {
|
|
70
|
+
const { editor, project, uri } = this.language;
|
|
71
|
+
const method = KINDS[kind].method;
|
|
72
|
+
if (!project.service.supports(method, uri)) {
|
|
73
|
+
this.tell(this.say(KINDS[kind].unavailable));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const token = ++this.token;
|
|
77
|
+
let found: { locations: Location[]; targets: Map<string, Range> };
|
|
78
|
+
try {
|
|
79
|
+
found = await this.fetch(kind, position);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!(error instanceof StaleResultError)) {
|
|
82
|
+
this.tell(this.say('failed', { message: error instanceof Error ? error.message : String(error) }));
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (token !== this.token) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const { targets } = found;
|
|
90
|
+
const unique = found.locations.filter(
|
|
91
|
+
(location, index) =>
|
|
92
|
+
found.locations.findIndex(
|
|
93
|
+
(other) =>
|
|
94
|
+
other.uri === location.uri &&
|
|
95
|
+
other.range.start.line === location.range.start.line &&
|
|
96
|
+
other.range.start.character === location.range.start.character
|
|
97
|
+
) === index
|
|
98
|
+
);
|
|
99
|
+
if (unique.length === 0) {
|
|
100
|
+
this.tell(this.say(KINDS[kind].none));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const uris = [...new Set(unique.map((location) => location.uri))].slice(0, PEEK_READ_FILES);
|
|
104
|
+
const texts = new Map<string, string>();
|
|
105
|
+
await Promise.all(
|
|
106
|
+
uris.map(async (target) => {
|
|
107
|
+
const text = await project.readText(target);
|
|
108
|
+
if (text !== null) {
|
|
109
|
+
texts.set(target, text);
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
if (token !== this.token) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.texts = texts;
|
|
117
|
+
this.kind = kind;
|
|
118
|
+
this.targets = targets;
|
|
119
|
+
const files = peekFilesOf(unique, uri, (target) => texts.get(target) ?? null);
|
|
120
|
+
this.places = new Map(files.flatMap((file: PeekFile) => file.places.map((place) => [place.id, place.location] as const)));
|
|
121
|
+
this.order = files.flatMap((file) => file.places.map((place) => place.id));
|
|
122
|
+
// The references open on the first place that is not the one the caret is on, which is what a person came to see.
|
|
123
|
+
const first =
|
|
124
|
+
kind === 'definitions'
|
|
125
|
+
? files[0]!.places[0]!
|
|
126
|
+
: (files.flatMap((file) => file.places).find((place) => !(place.location.uri === uri && place.line === position.line)) ?? files[0]!.places[0]!);
|
|
127
|
+
this.line = position.line;
|
|
128
|
+
this.language.popups.setState({ peek: { kind, container: null, files, active: first.id, collapsed: [], count: unique.length, preview: null } });
|
|
129
|
+
this.select(first.id);
|
|
130
|
+
// The editor draws a row once per id, so a peek opened over another needs an id of its own to get a container.
|
|
131
|
+
editor.setWidgets([{ id: `peek-${token}`, line: this.line, height: PEEK_HEIGHT, render: (container) => this.mounted(container) }]);
|
|
132
|
+
// The keys come through the editor, which a click on the hover's link or a menu command has taken the focus from.
|
|
133
|
+
editor.focus();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* The definition of the name at the caret, as its source between the lines of the file. */
|
|
137
|
+
openDefinition(position: EditorPosition = this.language.editor.getCaret()): Promise<void> {
|
|
138
|
+
return this.open(position, 'definitions');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async fetch(kind: PeekKind, position: EditorPosition): Promise<{ locations: Location[]; targets: Map<string, Range> }> {
|
|
142
|
+
const { project, uri } = this.language;
|
|
143
|
+
if (kind === 'references') {
|
|
144
|
+
// Without the declaration, so the list holds what the usages count above a declaration counts.
|
|
145
|
+
return { locations: (await project.service.references(uri, position, false)) ?? [], targets: new Map() };
|
|
146
|
+
}
|
|
147
|
+
const places = uniquePlaces(await project.service.definition(uri, position));
|
|
148
|
+
const targets = new Map(places.flatMap(({ location, target }) => (target === null ? [] : [[placeKey(location), target] as const])));
|
|
149
|
+
return { locations: places.map(({ location }) => location), targets };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
close(): void {
|
|
153
|
+
this.token++;
|
|
154
|
+
if (this.language.popups.getState().peek === null) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.language.popups.setState({ peek: null });
|
|
158
|
+
this.language.editor.setWidgets([]);
|
|
159
|
+
this.language.editor.focus();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Shows the code around a place. */
|
|
163
|
+
select(id: string): void {
|
|
164
|
+
const view = this.language.popups.getState().peek;
|
|
165
|
+
const location = this.places.get(id);
|
|
166
|
+
if (view === null || location === undefined) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const text = this.texts.get(location.uri);
|
|
170
|
+
const line = location.range.start.line;
|
|
171
|
+
const snippet =
|
|
172
|
+
text === undefined
|
|
173
|
+
? null
|
|
174
|
+
: this.kind === 'definitions'
|
|
175
|
+
? definitionSnippetOf(text, line, this.targets.get(placeKey(location)) ?? null, location.range)
|
|
176
|
+
: snippetOf(text, line, location.range);
|
|
177
|
+
this.language.popups.setState({ peek: { ...view, active: id, preview: snippet === null ? null : { ...snippet, uri: location.uri } } });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Folds the places of a file away from the list, or brings them back. */
|
|
181
|
+
toggleFile(uri: string): void {
|
|
182
|
+
const view = this.language.popups.getState().peek;
|
|
183
|
+
if (view !== null) {
|
|
184
|
+
const collapsed = view.collapsed.includes(uri) ? view.collapsed.filter((other) => other !== uri) : [...view.collapsed, uri];
|
|
185
|
+
this.language.popups.setState({ peek: { ...view, collapsed } });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Goes to a place and closes the peek. */
|
|
190
|
+
go(id: string): void {
|
|
191
|
+
const location = this.places.get(id);
|
|
192
|
+
if (location !== undefined) {
|
|
193
|
+
this.close();
|
|
194
|
+
this.language.goTo(location);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private mounted(container: HTMLElement): void {
|
|
199
|
+
const view = this.language.popups.getState().peek;
|
|
200
|
+
if (view !== null && view.container !== container) {
|
|
201
|
+
this.language.popups.setState({ peek: { ...view, container } });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private key(event: KeyboardEvent): boolean {
|
|
206
|
+
const view = this.language.popups.getState().peek;
|
|
207
|
+
if (view === null || event.metaKey || event.ctrlKey || event.altKey) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
switch (event.key) {
|
|
211
|
+
case 'ArrowDown':
|
|
212
|
+
this.step(view, 1);
|
|
213
|
+
return true;
|
|
214
|
+
case 'ArrowUp':
|
|
215
|
+
this.step(view, -1);
|
|
216
|
+
return true;
|
|
217
|
+
case 'Enter':
|
|
218
|
+
this.go(view.active);
|
|
219
|
+
return true;
|
|
220
|
+
case 'Escape':
|
|
221
|
+
this.close();
|
|
222
|
+
return true;
|
|
223
|
+
default:
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* The next place in the list a person can see, which a folded file has none of. */
|
|
229
|
+
private step(view: PeekView, direction: 1 | -1): void {
|
|
230
|
+
const index = this.order.indexOf(view.active);
|
|
231
|
+
for (let next = index + direction; next >= 0 && next < this.order.length; next += direction) {
|
|
232
|
+
const id = this.order[next]!;
|
|
233
|
+
if (!view.collapsed.includes(this.places.get(id)!.uri)) {
|
|
234
|
+
this.select(id);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private tell(title: string): void {
|
|
241
|
+
this.language.project.host.notify?.({ id: TOAST_ID, kind: 'error', title });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private say(key: string, options?: Record<string, unknown>): string {
|
|
245
|
+
return this.language.project.i18n.t(`editor:language.peek.${key}`, options);
|
|
246
|
+
}
|
|
247
|
+
}
|