@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,87 @@
|
|
|
1
|
+
import type { Diagnostic } from '@adecore/lsp';
|
|
2
|
+
import type { LanguageService } from '@adecore/lsp';
|
|
3
|
+
import { comparePositions, severityOf } from './diagnostics-model.ts';
|
|
4
|
+
import type { ProblemCounts } from './diagnostics.ts';
|
|
5
|
+
|
|
6
|
+
/* One problem of a file and the server that reported it. */
|
|
7
|
+
export interface ProblemRow {
|
|
8
|
+
readonly diagnostic: Diagnostic;
|
|
9
|
+
readonly server: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* A file with problems; its path is a stored path (relative to the project folder, absolute outside it). */
|
|
13
|
+
export interface ProblemFile {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly rows: readonly ProblemRow[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2, hint: 3 } as const;
|
|
19
|
+
|
|
20
|
+
/* Worst first, then in the order of the file. */
|
|
21
|
+
export function compareRows(left: ProblemRow, right: ProblemRow): number {
|
|
22
|
+
return (
|
|
23
|
+
SEVERITY_ORDER[severityOf(left.diagnostic)] - SEVERITY_ORDER[severityOf(right.diagnostic)] ||
|
|
24
|
+
comparePositions(left.diagnostic.range.start, right.diagnostic.range.start)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Each report replaces the previous diagnostics from that source for the file. Files with no
|
|
30
|
+
* remaining problems drop out. Hints remain editor annotations rather than listed problems.
|
|
31
|
+
*/
|
|
32
|
+
export class ProjectProblems {
|
|
33
|
+
private readonly reports = new Map<string, Map<string, readonly Diagnostic[]>>();
|
|
34
|
+
private readonly listeners = new Set<() => void>();
|
|
35
|
+
private readonly stop: () => void;
|
|
36
|
+
private snapshot: readonly ProblemFile[] = [];
|
|
37
|
+
|
|
38
|
+
constructor(service: LanguageService, pathOf: (uri: string) => string = (uri) => uri) {
|
|
39
|
+
const subscription = service.onDiagnostics((report) => this.report(pathOf(report.uri), report.source, report.diagnostics));
|
|
40
|
+
this.stop = () => subscription.dispose();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
report(path: string, server: string, diagnostics: readonly Diagnostic[]): void {
|
|
44
|
+
const kept = diagnostics.filter((diagnostic) => severityOf(diagnostic) !== 'hint');
|
|
45
|
+
const servers = this.reports.get(path) ?? new Map<string, readonly Diagnostic[]>();
|
|
46
|
+
if (kept.length === 0) {
|
|
47
|
+
servers.delete(server);
|
|
48
|
+
} else {
|
|
49
|
+
servers.set(server, kept);
|
|
50
|
+
}
|
|
51
|
+
if (servers.size === 0) {
|
|
52
|
+
this.reports.delete(path);
|
|
53
|
+
} else {
|
|
54
|
+
this.reports.set(path, servers);
|
|
55
|
+
}
|
|
56
|
+
this.snapshot = [...this.reports]
|
|
57
|
+
.map(([file, byServer]): ProblemFile => ({
|
|
58
|
+
path: file,
|
|
59
|
+
rows: [...byServer].flatMap(([name, list]) => list.map((diagnostic): ProblemRow => ({ diagnostic, server: name }))).sort(compareRows)
|
|
60
|
+
}))
|
|
61
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
62
|
+
for (const listener of [...this.listeners]) {
|
|
63
|
+
listener();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* The same array until the next report, which is what a store a component reads must hand out. */
|
|
68
|
+
getSnapshot = (): readonly ProblemFile[] => this.snapshot;
|
|
69
|
+
|
|
70
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
71
|
+
this.listeners.add(listener);
|
|
72
|
+
return () => {
|
|
73
|
+
this.listeners.delete(listener);
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
dispose(): void {
|
|
78
|
+
this.stop();
|
|
79
|
+
this.listeners.clear();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function countsOf(files: readonly ProblemFile[]): ProblemCounts {
|
|
84
|
+
const rows = files.flatMap((file) => file.rows);
|
|
85
|
+
const count = (severity: number): number => rows.filter((row) => (row.diagnostic.severity ?? 1) === severity).length;
|
|
86
|
+
return { error: count(1), warning: count(2), info: count(3) };
|
|
87
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { diffLines } from '@adecore/merge';
|
|
2
|
+
import type { EditorPosition, EditorRange } from '@adecore/editor';
|
|
3
|
+
import { replacedWords } from './word-diff.ts';
|
|
4
|
+
|
|
5
|
+
/* The one-based lines a range covers; a selection that ends at the start of a line did not take that line. */
|
|
6
|
+
export interface LineSpan {
|
|
7
|
+
readonly startLine: number;
|
|
8
|
+
readonly endLine: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function lineSpanOf(range: EditorRange): LineSpan {
|
|
12
|
+
const endsOnBreak = range.end.character === 0 && range.end.line > range.start.line;
|
|
13
|
+
return { startLine: range.start.line + 1, endLine: endsOnBreak ? range.end.line : range.end.line + 1 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isEmptyRange(range: EditorRange): boolean {
|
|
17
|
+
return range.start.line === range.end.line && range.start.character === range.end.character;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* The selection, or the whole line under the caret while nothing is selected. */
|
|
21
|
+
export function inlineRangeOf(selection: EditorRange, lineLength: (line: number) => number): EditorRange {
|
|
22
|
+
if (!isEmptyRange(selection)) {
|
|
23
|
+
return selection;
|
|
24
|
+
}
|
|
25
|
+
const { line } = selection.start;
|
|
26
|
+
return { start: { line, character: 0 }, end: { line, character: lineLength(line) } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* A problem the card lists for the lines, as the language servers reported it. */
|
|
30
|
+
export interface InlineProblem {
|
|
31
|
+
/* One-based. */
|
|
32
|
+
readonly line: number;
|
|
33
|
+
readonly severity: 'error' | 'warning' | 'info' | 'hint';
|
|
34
|
+
readonly message: string;
|
|
35
|
+
/* The server's code, with its source in front when it names one: `ts 2345`. */
|
|
36
|
+
readonly code: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function problemsOnLines<T extends { range: EditorRange }>(
|
|
40
|
+
diagnostics: readonly T[],
|
|
41
|
+
span: LineSpan,
|
|
42
|
+
describe: (diagnostic: T) => Omit<InlineProblem, 'line'>
|
|
43
|
+
): InlineProblem[] {
|
|
44
|
+
return diagnostics
|
|
45
|
+
.filter((diagnostic) => diagnostic.range.start.line + 1 <= span.endLine && diagnostic.range.end.line + 1 >= span.startLine)
|
|
46
|
+
.map((diagnostic) => ({ line: Math.max(diagnostic.range.start.line + 1, span.startLine), ...describe(diagnostic) }))
|
|
47
|
+
.sort((left, right) => left.line - right.line);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface FenceStart {
|
|
51
|
+
char: string;
|
|
52
|
+
length: number;
|
|
53
|
+
indent: number;
|
|
54
|
+
info: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const OPENING = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
58
|
+
|
|
59
|
+
function openingOf(line: string): FenceStart | null {
|
|
60
|
+
const match = OPENING.exec(line);
|
|
61
|
+
if (match === null) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const [, indent = '', marks = '', info = ''] = match;
|
|
65
|
+
// The info string of a backtick fence holds no backtick, which is what keeps a line of inline code from opening one.
|
|
66
|
+
if (marks.startsWith('`') && info.includes('`')) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return { char: marks[0]!, length: marks.length, indent: indent.length, info: info.trim() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function closes(line: string, start: FenceStart): boolean {
|
|
73
|
+
const match = /^ {0,3}(`+|~+)\s*$/.exec(line);
|
|
74
|
+
return match !== null && match[1]![0] === start.char && match[1]!.length >= start.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isReplacement(info: string): boolean {
|
|
78
|
+
return info
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.split(/[\s,:=]+/)
|
|
81
|
+
.includes('replacement');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface InlineAnswer {
|
|
85
|
+
/* The text of the last closed block labeled `replacement`; null when the answer has none. */
|
|
86
|
+
readonly replacement: string | null;
|
|
87
|
+
/* The answer without that block: what the agent said about it, or all of it. */
|
|
88
|
+
readonly rest: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* Reads the answer the way a markdown reader does: a fence closes at a line of at least as many marks,
|
|
93
|
+
* so a fence inside the code stays code. A block that never closes is a turn that was cut off, and
|
|
94
|
+
* applying half of it would be worse than showing the answer as text, so it counts as no block.
|
|
95
|
+
*/
|
|
96
|
+
export function parseAnswer(text: string): InlineAnswer {
|
|
97
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
98
|
+
let open: { start: FenceStart; from: number; body: string[] } | null = null;
|
|
99
|
+
let found: { from: number; to: number; body: string[] } | null = null;
|
|
100
|
+
for (const [index, line] of lines.entries()) {
|
|
101
|
+
if (open === null) {
|
|
102
|
+
const start = openingOf(line);
|
|
103
|
+
if (start !== null) {
|
|
104
|
+
open = { start, from: index, body: [] };
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (closes(line, open.start)) {
|
|
109
|
+
if (isReplacement(open.start.info)) {
|
|
110
|
+
found = { from: open.from, to: index, body: open.body };
|
|
111
|
+
}
|
|
112
|
+
open = null;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
open.body.push(line.replace(new RegExp(`^ {0,${open.start.indent}}`), ''));
|
|
116
|
+
}
|
|
117
|
+
if (found === null) {
|
|
118
|
+
return { replacement: null, rest: text.trim() };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
replacement: found.body.join('\n'),
|
|
122
|
+
rest: [...lines.slice(0, found.from), ...lines.slice(found.to + 1)]
|
|
123
|
+
.join('\n')
|
|
124
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
125
|
+
.trim()
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* The proposal in the shape of the text it replaces: its line breaks and whether it ends on one. */
|
|
130
|
+
export function fitReplacement(selected: string, replacement: string): string {
|
|
131
|
+
const breaks = selected.includes('\r\n') ? '\r\n' : '\n';
|
|
132
|
+
let fitted = replacement.replace(/\r\n/g, '\n');
|
|
133
|
+
const endsOnBreak = selected.endsWith('\n');
|
|
134
|
+
if (fitted !== '' && endsOnBreak && !fitted.endsWith('\n')) {
|
|
135
|
+
fitted += '\n';
|
|
136
|
+
} else if (!endsOnBreak && fitted.endsWith('\n')) {
|
|
137
|
+
fitted = fitted.slice(0, -1);
|
|
138
|
+
}
|
|
139
|
+
return breaks === '\n' ? fitted : fitted.replace(/\n/g, breaks);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Where the caret stands after `text` was typed at `start`. */
|
|
143
|
+
export function endOfInsertion(start: EditorPosition, text: string): EditorPosition {
|
|
144
|
+
const lines = text.split('\n');
|
|
145
|
+
if (lines.length === 1) {
|
|
146
|
+
return { line: start.line, character: start.character + text.length };
|
|
147
|
+
}
|
|
148
|
+
return { line: start.line + lines.length - 1, character: lines.at(-1)!.length };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* One stretch of the proposal as the card draws it. */
|
|
152
|
+
export interface DiffSegment {
|
|
153
|
+
readonly kind: 'same' | 'added' | 'removed';
|
|
154
|
+
readonly text: string;
|
|
155
|
+
/* One-based number of its first line in the proposal; absent for lines the proposal drops. */
|
|
156
|
+
readonly firstLine?: number;
|
|
157
|
+
/* For added lines, the selected lines they take the place of; absent for lines that stand where nothing stood. */
|
|
158
|
+
readonly replaces?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function linesOf(text: string): string[] {
|
|
162
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
163
|
+
return lines.length > 1 && lines.at(-1) === '' ? lines.slice(0, -1) : lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* The proposal against the selection, line by line. Lines the proposal changes show as added, since
|
|
168
|
+
* the old ones are the selected text above the card; lines it drops without a replacement are the
|
|
169
|
+
* only removed ones, because nothing in the proposal would show them gone.
|
|
170
|
+
*/
|
|
171
|
+
export function diffSegments(selected: string, proposal: string, startLine: number): DiffSegment[] {
|
|
172
|
+
const base = linesOf(selected);
|
|
173
|
+
const other = linesOf(proposal);
|
|
174
|
+
const segments: DiffSegment[] = [];
|
|
175
|
+
let cursor = 0;
|
|
176
|
+
const same = (until: number): void => {
|
|
177
|
+
if (until > cursor) {
|
|
178
|
+
segments.push({ kind: 'same', text: other.slice(cursor, until).join('\n'), firstLine: startLine + cursor });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
for (const change of diffLines(base, other)) {
|
|
182
|
+
same(change.otherStart);
|
|
183
|
+
if (change.otherEnd > change.otherStart) {
|
|
184
|
+
segments.push({
|
|
185
|
+
kind: 'added',
|
|
186
|
+
text: other.slice(change.otherStart, change.otherEnd).join('\n'),
|
|
187
|
+
firstLine: startLine + change.otherStart,
|
|
188
|
+
...(change.baseEnd > change.baseStart ? { replaces: base.slice(change.baseStart, change.baseEnd).join('\n') } : {})
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
segments.push({ kind: 'removed', text: base.slice(change.baseStart, change.baseEnd).join('\n') });
|
|
192
|
+
}
|
|
193
|
+
cursor = change.otherEnd;
|
|
194
|
+
}
|
|
195
|
+
same(other.length);
|
|
196
|
+
return segments;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Per line of an added stretch, the character ranges of the words that differ from the line it replaces, for the stronger tint; undefined when none do. */
|
|
200
|
+
export function emphasisOf(segment: DiffSegment): Array<Array<[number, number]>> | undefined {
|
|
201
|
+
if (segment.kind !== 'added' || segment.replaces === undefined) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const ranges = replacedWords(linesOf(segment.text), linesOf(segment.replaces));
|
|
205
|
+
return ranges.some((line) => line.length > 0) ? ranges : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* The offset of a position in a text whose lines end in `\n` or `\r\n`; null when the text is not that long. */
|
|
209
|
+
export function offsetOf(text: string, position: EditorPosition): number | null {
|
|
210
|
+
let offset = 0;
|
|
211
|
+
for (let line = 0; line < position.line; line++) {
|
|
212
|
+
const end = text.indexOf('\n', offset);
|
|
213
|
+
if (end === -1) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
offset = end + 1;
|
|
217
|
+
}
|
|
218
|
+
const lineEnd = text.indexOf('\n', offset);
|
|
219
|
+
const length = (lineEnd === -1 ? text.length : lineEnd) - offset;
|
|
220
|
+
return position.character > length ? null : offset + position.character;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* The text in a range of `text`, or null when the range is not in it. */
|
|
224
|
+
export function textInRange(text: string, range: EditorRange): string | null {
|
|
225
|
+
const from = offsetOf(text, range.start);
|
|
226
|
+
const to = offsetOf(text, range.end);
|
|
227
|
+
return from === null || to === null || to < from ? null : text.slice(from, to);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* `text` with the range replaced; null when the range is not in it. */
|
|
231
|
+
export function replaceRange(text: string, range: EditorRange, replacement: string): string | null {
|
|
232
|
+
const from = offsetOf(text, range.start);
|
|
233
|
+
const to = offsetOf(text, range.end);
|
|
234
|
+
return from === null || to === null || to < from ? null : `${text.slice(0, from)}${replacement}${text.slice(to)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Where `selected` stands in `text` now: its own range while it still holds, and otherwise the one
|
|
239
|
+
* place it occurs when lines moved. Null when it is gone or ambiguous, which is a selection that changed.
|
|
240
|
+
*/
|
|
241
|
+
export function locateSelection(text: string, range: EditorRange, selected: string): EditorRange | null {
|
|
242
|
+
if (textInRange(text, range) === selected) {
|
|
243
|
+
return range;
|
|
244
|
+
}
|
|
245
|
+
if (selected === '') {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const first = text.indexOf(selected);
|
|
249
|
+
if (first === -1 || text.indexOf(selected, first + 1) !== -1) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return { start: positionAt(text, first), end: positionAt(text, first + selected.length) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function positionAt(text: string, offset: number): EditorPosition {
|
|
256
|
+
const before = text.slice(0, offset);
|
|
257
|
+
const line = before.split('\n').length - 1;
|
|
258
|
+
return { line, character: offset - (before.lastIndexOf('\n') + 1) };
|
|
259
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CompletionItem } from '@adecore/lsp';
|
|
2
|
+
import { qualifierOf } from './completion-model.ts';
|
|
3
|
+
|
|
4
|
+
const LIMIT = 200;
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The suggestions a person took lately, so one they use again comes first among equal matches. It lives in
|
|
8
|
+
* memory only, one per language, and forgets the oldest choice past a few hundred.
|
|
9
|
+
*/
|
|
10
|
+
export class RecentChoices {
|
|
11
|
+
private readonly order = new Map<string, number>();
|
|
12
|
+
private tick = 0;
|
|
13
|
+
|
|
14
|
+
/* An item is told by its label, its kind and where it comes from, so two `Property` of two namespaces are two choices. */
|
|
15
|
+
static keyOf(item: CompletionItem): string {
|
|
16
|
+
return `${item.label}\0${item.kind ?? ''}\0${qualifierOf(item)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
record(item: CompletionItem): void {
|
|
20
|
+
const key = RecentChoices.keyOf(item);
|
|
21
|
+
this.order.delete(key);
|
|
22
|
+
this.order.set(key, ++this.tick);
|
|
23
|
+
if (this.order.size > LIMIT) {
|
|
24
|
+
this.order.delete(this.order.keys().next().value!);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Higher for a later choice, 0 for an item never chosen. */
|
|
29
|
+
recency(item: CompletionItem): number {
|
|
30
|
+
return this.order.get(RecentChoices.keyOf(item)) ?? 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stores = new Map<string, RecentChoices>();
|
|
35
|
+
|
|
36
|
+
export function recentChoicesOf(languageId: string): RecentChoices {
|
|
37
|
+
let store = stores.get(languageId);
|
|
38
|
+
if (store === undefined) {
|
|
39
|
+
store = new RecentChoices();
|
|
40
|
+
stores.set(languageId, store);
|
|
41
|
+
}
|
|
42
|
+
return store;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* For a test that must not hear the choices an earlier one made. */
|
|
46
|
+
export function forgetRecentChoices(): void {
|
|
47
|
+
stores.clear();
|
|
48
|
+
}
|
package/src/refresher.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { StaleResultError } from '@adecore/lsp';
|
|
2
|
+
import { realTimers, type Timers } from './timers.ts';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Asks again for something a document's text decides, such as its colors or its hints: after a pause
|
|
6
|
+
* in typing, when a server says what it offers changed, and never twice at once. A newer ask aborts the
|
|
7
|
+
* one before it, and an answer for a text that has moved on is dropped, since another ask is on its way.
|
|
8
|
+
*/
|
|
9
|
+
export class Refresher {
|
|
10
|
+
private timer: unknown;
|
|
11
|
+
private controller: AbortController | null = null;
|
|
12
|
+
private disposed = false;
|
|
13
|
+
private readonly timers: Timers;
|
|
14
|
+
private readonly delay: number;
|
|
15
|
+
private readonly run: (signal: AbortSignal) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
constructor(run: (signal: AbortSignal) => Promise<void>, delay: number, timers: Timers = realTimers) {
|
|
18
|
+
this.run = run;
|
|
19
|
+
this.delay = delay;
|
|
20
|
+
this.timers = timers;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* After a pause: every call starts the wait again. */
|
|
24
|
+
later(): void {
|
|
25
|
+
this.timers.clear(this.timer);
|
|
26
|
+
this.timer = this.timers.set(() => this.now(), this.delay);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
now(): void {
|
|
30
|
+
if (this.disposed) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.timers.clear(this.timer);
|
|
34
|
+
this.controller?.abort();
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
this.controller = controller;
|
|
37
|
+
this.run(controller.signal).catch((error: unknown) => {
|
|
38
|
+
// A server that cannot answer yet is asked again when it says it can; a stale text is asked again by its own change.
|
|
39
|
+
if (!(error instanceof StaleResultError) && !controller.signal.aborted && !isQuiet(error)) {
|
|
40
|
+
console.warn('Language request failed', error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
dispose(): void {
|
|
46
|
+
this.disposed = true;
|
|
47
|
+
this.timers.clear(this.timer);
|
|
48
|
+
this.controller?.abort();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The failures of a server that is not up, does not offer the feature or was asked to stop, which are not worth a line in the console. */
|
|
53
|
+
function isQuiet(error: unknown): boolean {
|
|
54
|
+
const code = (error as { code?: number } | null)?.code;
|
|
55
|
+
return code === -32002 || code === -32601 || code === -32800 || code === -32801;
|
|
56
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { formatAgo, formatDay, formatDayWithYear } from '@adecore/ui/format';
|
|
2
|
+
|
|
3
|
+
export function relativeTime(at: number, now: number): string {
|
|
4
|
+
const seconds = Math.max(0, now - at);
|
|
5
|
+
if (seconds < 7 * 24 * 60 * 60) {
|
|
6
|
+
return formatAgo(seconds * 1000);
|
|
7
|
+
}
|
|
8
|
+
const date = new Date(at * 1000);
|
|
9
|
+
return date.getFullYear() === new Date(now * 1000).getFullYear() ? formatDay(date) : formatDayWithYear(date);
|
|
10
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { EditorPosition, EditorRange } from '@adecore/editor';
|
|
2
|
+
import type { Location, PrepareRenameResult, TextEdit, WorkspaceEdit } from '@adecore/lsp';
|
|
3
|
+
import { isIdentifierCharacter } from './completion-model.ts';
|
|
4
|
+
import { entriesOf } from './workspace-edit.ts';
|
|
5
|
+
|
|
6
|
+
/* The identifier the caret is in or touches; null where there is none. */
|
|
7
|
+
export function wordRangeAt(lineText: string, position: EditorPosition): EditorRange | null {
|
|
8
|
+
let start = Math.min(position.character, lineText.length);
|
|
9
|
+
let end = start;
|
|
10
|
+
while (start > 0 && isIdentifierCharacter(lineText[start - 1]!)) {
|
|
11
|
+
start--;
|
|
12
|
+
}
|
|
13
|
+
while (end < lineText.length && isIdentifierCharacter(lineText[end]!)) {
|
|
14
|
+
end++;
|
|
15
|
+
}
|
|
16
|
+
return start === end ? null : { start: { line: position.line, character: start }, end: { line: position.line, character: end } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* What a server said about renaming at a position: the range to put the input over, and the name it starts with. */
|
|
20
|
+
export interface RenameTarget {
|
|
21
|
+
readonly range: EditorRange;
|
|
22
|
+
readonly placeholder: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The target of a prepare answer. A server that answers with a bare range or the default behavior leaves
|
|
27
|
+
* the name to the text, and one that answers null says nothing here can be renamed.
|
|
28
|
+
*/
|
|
29
|
+
export function renameTargetOf(
|
|
30
|
+
result: PrepareRenameResult,
|
|
31
|
+
textOf: (range: EditorRange) => string,
|
|
32
|
+
wordAtCaret: () => EditorRange | null
|
|
33
|
+
): RenameTarget | null {
|
|
34
|
+
if (result === null) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if ('placeholder' in result) {
|
|
38
|
+
return { range: result.range, placeholder: result.placeholder };
|
|
39
|
+
}
|
|
40
|
+
if ('start' in result) {
|
|
41
|
+
return { range: result, placeholder: textOf(result) };
|
|
42
|
+
}
|
|
43
|
+
const word = wordAtCaret();
|
|
44
|
+
return word === null ? null : { range: word, placeholder: textOf(word) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Where the places of one name are, in the open file and in how many files all together. */
|
|
48
|
+
export interface Occurrences {
|
|
49
|
+
readonly inFile: readonly EditorRange[];
|
|
50
|
+
readonly count: number;
|
|
51
|
+
readonly files: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function occurrencesOf(locations: readonly Location[], uri: string): Occurrences {
|
|
55
|
+
return {
|
|
56
|
+
inFile: locations.filter((location) => location.uri === uri).map((location) => location.range),
|
|
57
|
+
count: locations.length,
|
|
58
|
+
files: new Set(locations.map((location) => location.uri)).size
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* One line a rename changes: the line as it is and as it will be. */
|
|
63
|
+
export interface RenameRow {
|
|
64
|
+
/* One-based. */
|
|
65
|
+
readonly line: number;
|
|
66
|
+
readonly before: string;
|
|
67
|
+
readonly after: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RenameFile {
|
|
71
|
+
readonly uri: string;
|
|
72
|
+
readonly rows: readonly RenameRow[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lineStarts(text: string): number[] {
|
|
76
|
+
const starts = [0];
|
|
77
|
+
for (let index = 0; index < text.length; index++) {
|
|
78
|
+
const code = text.charCodeAt(index);
|
|
79
|
+
if (code === 13 && text.charCodeAt(index + 1) === 10) {
|
|
80
|
+
index++;
|
|
81
|
+
starts.push(index + 1);
|
|
82
|
+
} else if (code === 10 || code === 13) {
|
|
83
|
+
starts.push(index + 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return starts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* The lines of a file an edit touches, each once, as they read before and after. An edit across lines is shown from its first line. */
|
|
90
|
+
export function renameRowsOf(text: string, edits: readonly TextEdit[]): RenameRow[] {
|
|
91
|
+
const starts = lineStarts(text);
|
|
92
|
+
const lineAt = (line: number): { from: number; to: number } => {
|
|
93
|
+
const from = starts[line] ?? text.length;
|
|
94
|
+
let to = (starts[line + 1] ?? text.length + 1) - 1;
|
|
95
|
+
while (to > from && (text[to - 1] === '\n' || text[to - 1] === '\r')) {
|
|
96
|
+
to--;
|
|
97
|
+
}
|
|
98
|
+
return { from, to: Math.min(to, text.length) };
|
|
99
|
+
};
|
|
100
|
+
const byLine = new Map<number, TextEdit[]>();
|
|
101
|
+
for (const edit of edits) {
|
|
102
|
+
byLine.set(edit.range.start.line, [...(byLine.get(edit.range.start.line) ?? []), edit]);
|
|
103
|
+
}
|
|
104
|
+
return [...byLine]
|
|
105
|
+
.sort((left, right) => left[0] - right[0])
|
|
106
|
+
.map(([line, own]) => {
|
|
107
|
+
const { from, to } = lineAt(line);
|
|
108
|
+
const before = text.slice(from, to);
|
|
109
|
+
let after = before;
|
|
110
|
+
for (const edit of [...own].sort((left, right) => right.range.start.character - left.range.start.character)) {
|
|
111
|
+
const end = edit.range.end.line === line ? edit.range.end.character : before.length;
|
|
112
|
+
after = after.slice(0, edit.range.start.character) + edit.newText + after.slice(end);
|
|
113
|
+
}
|
|
114
|
+
return { line: line + 1, before, after };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Resource renames are omitted; create/delete operations refuse the text-only preview with null. */
|
|
119
|
+
export async function renamePreviewOf(edit: WorkspaceEdit, textOf: (uri: string) => Promise<string | null>): Promise<RenameFile[] | null> {
|
|
120
|
+
const entries = entriesOf(edit);
|
|
121
|
+
if (entries === null) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const files: RenameFile[] = [];
|
|
125
|
+
for (const [uri, list] of entries) {
|
|
126
|
+
const text = await textOf(uri);
|
|
127
|
+
files.push({ uri, rows: text === null ? [] : renameRowsOf(text, list.flat()) });
|
|
128
|
+
}
|
|
129
|
+
return files;
|
|
130
|
+
}
|