@adecore/editor-react 0.17.0-beta.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/HoverCard.d.ts +6 -0
- package/dist/HoverCard.js +20 -5
- package/dist/code-actions-model.js +1 -1
- package/dist/folding.d.ts +2 -4
- package/dist/folding.js +8 -2
- package/dist/language-ids.js +4 -2
- package/dist/locales/en.json +1 -0
- package/dist/locales/nl.json +1 -0
- package/dist/workspace-edit.d.ts +1 -0
- package/dist/workspace-edit.js +46 -7
- package/package.json +5 -5
- package/src/HoverCard.tsx +44 -25
- package/src/code-actions-model.ts +1 -1
- package/src/folding.ts +11 -5
- package/src/language-ids.ts +4 -2
- package/src/locales/en.json +1 -0
- package/src/locales/nl.json +1 -0
- package/src/workspace-edit.ts +70 -10
package/dist/HoverCard.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { type Location } from '@adecore/lsp';
|
|
1
2
|
import type { EditorPosition } from '@adecore/editor';
|
|
2
3
|
import type { EditorLanguage } from './editor-language.ts';
|
|
3
4
|
import { type Problem } from './diagnostics-model.ts';
|
|
4
5
|
import type { HoverInfo } from './popups.ts';
|
|
6
|
+
export declare function DefinitionPlace({ definition, uri, folder }: {
|
|
7
|
+
definition: Location;
|
|
8
|
+
uri: string;
|
|
9
|
+
folder: string;
|
|
10
|
+
}): import("react").JSX.Element | null;
|
|
5
11
|
export declare function HoverCard({ language, problems, info, anchor, position }: {
|
|
6
12
|
language: EditorLanguage;
|
|
7
13
|
problems: readonly Problem[];
|
package/dist/HoverCard.js
CHANGED
|
@@ -9,6 +9,22 @@ import { codeLabelOf, severityOf } from './diagnostics-model.js';
|
|
|
9
9
|
import { SymbolSections } from './HoverSections.js';
|
|
10
10
|
const SEVERITY_ICONS = { error: CircleX, warning: TriangleAlert, info: Info, hint: Info };
|
|
11
11
|
const SEVERITY_COLORS = { error: 'text-status-error', warning: 'text-status-needs-you', info: 'text-status-running', hint: 'text-text-muted' };
|
|
12
|
+
/*
|
|
13
|
+
* Where a definition is: only its line in the file of the card, else the file and the line, with a long name cut
|
|
14
|
+
* short before the line. A definition outside the project folder, such as the stubs of a standard library, has none.
|
|
15
|
+
*/
|
|
16
|
+
export function DefinitionPlace({ definition, uri, folder }) {
|
|
17
|
+
const { t } = useTranslation('editor');
|
|
18
|
+
const path = fileUriToPath(definition.uri);
|
|
19
|
+
const line = formatNumber(definition.range.start.line + 1);
|
|
20
|
+
if (path === null || (folder !== '' && !path.startsWith(`${folder}/`))) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (definition.uri === uri) {
|
|
24
|
+
return _jsx("span", { className: "text-text-faint", children: t('language.hover.line', { line }) });
|
|
25
|
+
}
|
|
26
|
+
return (_jsxs("span", { className: "flex min-w-0 font-mono text-text-faint", children: [_jsx("span", { className: "truncate", children: basenameOf(path) }), _jsxs("span", { className: "shrink-0", children: [":", line] })] }));
|
|
27
|
+
}
|
|
12
28
|
function ProblemSection({ problem, language }) {
|
|
13
29
|
const { t } = useTranslation('editor');
|
|
14
30
|
const { diagnostic } = problem;
|
|
@@ -27,12 +43,11 @@ function ProblemSection({ problem, language }) {
|
|
|
27
43
|
}
|
|
28
44
|
export function HoverCard({ language, problems, info, anchor, position }) {
|
|
29
45
|
const { t } = useTranslation('editor');
|
|
30
|
-
const place = info?.definition === null || info === null ? null : fileUriToPath(info.definition.uri);
|
|
31
46
|
return (_jsxs("div", { className: "flex w-max min-w-[280px] max-w-[min(520px,calc(100vw-16px))] flex-col divide-y divide-border", children: [info !== null && (_jsxs(_Fragment, { children: [_jsx(SymbolSections, { text: info.text, onName: (name, declared) => {
|
|
32
47
|
language.hover.hide();
|
|
33
48
|
void language.navigation.goToName(name, anchor, name === (declared ?? info.word) ? info.definition : null);
|
|
34
|
-
} }), _jsxs("div", { className: "flex
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
} }), _jsxs("div", { className: "flex flex-col gap-0.5 px-3 py-1.5 text-xs", children: [_jsxs("div", { className: "flex items-center gap-3 whitespace-nowrap", children: [info.definition !== null && (_jsx("button", { type: "button", className: "text-accent hover:underline", onClick: () => language.goTo(info.definition), children: t('language.hover.definition') })), info.references !== null && info.references > 0 && (_jsx("button", { type: "button", className: "text-accent hover:underline", onClick: () => {
|
|
50
|
+
language.hover.hide();
|
|
51
|
+
void language.peek.open(position);
|
|
52
|
+
}, children: t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) }) }))] }), info.definition !== null && _jsx(DefinitionPlace, { definition: info.definition, uri: language.uri, folder: language.project.folder })] })] })), problems.map((problem, index) => (_jsx(ProblemSection, { problem: problem, language: language }, index)))] }));
|
|
38
53
|
}
|
|
@@ -74,7 +74,7 @@ const PREVIEW_LINES = 3;
|
|
|
74
74
|
function linesOf(text) {
|
|
75
75
|
return text.split(/\r\n|\r|\n/);
|
|
76
76
|
}
|
|
77
|
-
/* Null when the edit changes nothing, or
|
|
77
|
+
/* Null when the edit changes nothing, or deletes a file, which a preview of lines cannot show. A file it creates counts as another file. */
|
|
78
78
|
export function previewOf(text, edit, uri) {
|
|
79
79
|
const entries = entriesOf(edit);
|
|
80
80
|
if (entries === null) {
|
package/dist/folding.d.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { EditorFoldRangeHint, EditorFoldSymbol } from '@adecore/editor';
|
|
2
2
|
import type { DocumentSymbolResult, FoldingRange } from '@adecore/lsp';
|
|
3
3
|
import type { EditorLanguage } from './editor-language.ts';
|
|
4
4
|
import { type Timers } from './timers.ts';
|
|
5
5
|
export declare function foldSymbolsOf(result: DocumentSymbolResult): EditorFoldSymbol[];
|
|
6
|
-
export declare function foldRangesOf(ranges: readonly FoldingRange[] | null):
|
|
7
|
-
kind?: string;
|
|
8
|
-
})[];
|
|
6
|
+
export declare function foldRangesOf(ranges: readonly FoldingRange[] | null): EditorFoldRangeHint[];
|
|
9
7
|
export declare class FoldingFeature {
|
|
10
8
|
private symbols;
|
|
11
9
|
private ranges;
|
package/dist/folding.js
CHANGED
|
@@ -43,11 +43,17 @@ export function foldSymbolsOf(result) {
|
|
|
43
43
|
}
|
|
44
44
|
return found;
|
|
45
45
|
}
|
|
46
|
-
/* The ranges a server folds that span more than one line. */
|
|
46
|
+
/* The ranges a server folds that span more than one line, with where a range starts and ends within its lines when it says. */
|
|
47
47
|
export function foldRangesOf(ranges) {
|
|
48
48
|
return (ranges ?? [])
|
|
49
49
|
.filter((range) => range.endLine > range.startLine)
|
|
50
|
-
.map((range) => ({
|
|
50
|
+
.map((range) => ({
|
|
51
|
+
startLine: range.startLine,
|
|
52
|
+
endLine: range.endLine,
|
|
53
|
+
...(range.kind === undefined ? {} : { kind: range.kind }),
|
|
54
|
+
...(range.startCharacter === undefined ? {} : { startCharacter: range.startCharacter }),
|
|
55
|
+
...(range.endCharacter === undefined ? {} : { endCharacter: range.endCharacter })
|
|
56
|
+
}));
|
|
51
57
|
}
|
|
52
58
|
/*
|
|
53
59
|
* What the servers know about folding: the bodies of the symbols (read from the symbols the document already
|
package/dist/language-ids.js
CHANGED
|
@@ -15,7 +15,8 @@ const LSP_IDS = {
|
|
|
15
15
|
yaml: 'yaml',
|
|
16
16
|
python: 'python',
|
|
17
17
|
shellscript: 'shellscript',
|
|
18
|
-
docker: 'dockerfile'
|
|
18
|
+
docker: 'dockerfile',
|
|
19
|
+
twig: 'twig'
|
|
19
20
|
};
|
|
20
21
|
/* The LSP language id of a file, or null when no language server here knows its language. */
|
|
21
22
|
export function lspLanguageIdOf(language) {
|
|
@@ -41,7 +42,8 @@ const PATH_IDS = {
|
|
|
41
42
|
css: 'css',
|
|
42
43
|
scss: 'scss',
|
|
43
44
|
html: 'html',
|
|
44
|
-
md: 'markdown'
|
|
45
|
+
md: 'markdown',
|
|
46
|
+
twig: 'twig'
|
|
45
47
|
};
|
|
46
48
|
/* The highlighter id for the code of a file we only know by its path, such as a place another file refers to. */
|
|
47
49
|
export function shikiLanguageOfPath(path) {
|
package/dist/locales/en.json
CHANGED
package/dist/locales/nl.json
CHANGED
package/dist/workspace-edit.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ProjectFiles {
|
|
|
11
11
|
stage(files: readonly StagedFile[]): void;
|
|
12
12
|
save(files: readonly StagedFile[]): Promise<string | null>;
|
|
13
13
|
rename(from: string, to: string): Promise<string | null>;
|
|
14
|
+
create?(path: string, text: string): Promise<string | null>;
|
|
14
15
|
}
|
|
15
16
|
export interface WorkspaceEditHost {
|
|
16
17
|
editorOf(uri: string): Editor | undefined;
|
package/dist/workspace-edit.js
CHANGED
|
@@ -2,13 +2,16 @@ import { applyTextEdits, fileUriToPath } from '@adecore/lsp';
|
|
|
2
2
|
function refused(failureReason) {
|
|
3
3
|
return { applied: false, failureReason };
|
|
4
4
|
}
|
|
5
|
-
/* The steps of an edit; null when it
|
|
5
|
+
/* The steps of an edit; null when it deletes a file, which an edit is not applied with. */
|
|
6
6
|
function stepsOf(edit) {
|
|
7
7
|
const steps = [];
|
|
8
8
|
for (const change of edit.documentChanges ?? []) {
|
|
9
9
|
if ('textDocument' in change) {
|
|
10
10
|
steps.push({ kind: 'edits', uri: change.textDocument.uri, edits: change.edits });
|
|
11
11
|
}
|
|
12
|
+
else if (change.kind === 'create') {
|
|
13
|
+
steps.push({ kind: 'create', file: change });
|
|
14
|
+
}
|
|
12
15
|
else if (change.kind === 'rename') {
|
|
13
16
|
steps.push({ kind: 'rename', oldUri: change.oldUri, newUri: change.newUri });
|
|
14
17
|
}
|
|
@@ -23,7 +26,7 @@ function stepsOf(edit) {
|
|
|
23
26
|
}
|
|
24
27
|
return steps;
|
|
25
28
|
}
|
|
26
|
-
/* The text edits of an edit per document, as the entries a document has in it; null when it
|
|
29
|
+
/* The text edits of an edit per document, as the entries a document has in it; null when it deletes a file. */
|
|
27
30
|
export function entriesOf(edit) {
|
|
28
31
|
const steps = stepsOf(edit);
|
|
29
32
|
if (steps === null) {
|
|
@@ -48,13 +51,14 @@ function contentChangeOf(edit) {
|
|
|
48
51
|
* Applies an LSP workspace edit on the client, step by step in the order it says. A file an editor holds takes
|
|
49
52
|
* its edits as one undo step. Any other file gets its new text as an unsaved draft that a person saves, and
|
|
50
53
|
* nothing is written to disk on a server's word alone, unless the edit also moves a file: the edits and the
|
|
51
|
-
* move are then one change, so every file is saved through the machine and the move is made there.
|
|
52
|
-
* edit is
|
|
54
|
+
* move are then one change, so every file is saved through the machine and the move is made there. A file
|
|
55
|
+
* the edit creates is made through the machine with the text the edit writes into it. The edit is checked
|
|
56
|
+
* against every text first, so one that does not fit changes nothing.
|
|
53
57
|
*/
|
|
54
58
|
export async function applyWorkspaceEdit(edit, host) {
|
|
55
59
|
const steps = stepsOf(edit);
|
|
56
60
|
if (steps === null) {
|
|
57
|
-
return refused('
|
|
61
|
+
return refused('Deleting files is not supported yet');
|
|
58
62
|
}
|
|
59
63
|
const planned = await plan(steps, host);
|
|
60
64
|
if (typeof planned === 'string') {
|
|
@@ -63,8 +67,8 @@ export async function applyWorkspaceEdit(edit, host) {
|
|
|
63
67
|
const moving = planned.some((item) => item.kind === 'rename');
|
|
64
68
|
const staged = [];
|
|
65
69
|
for (const item of planned) {
|
|
66
|
-
if (item.kind === 'rename') {
|
|
67
|
-
const reason = await host.files.rename(item.oldPath, item.newPath);
|
|
70
|
+
if (item.kind === 'rename' || item.kind === 'create') {
|
|
71
|
+
const reason = item.kind === 'rename' ? await host.files.rename(item.oldPath, item.newPath) : await host.files.create(item.path, item.text);
|
|
68
72
|
if (reason !== null) {
|
|
69
73
|
return refused(reason);
|
|
70
74
|
}
|
|
@@ -105,8 +109,21 @@ async function plan(steps, host) {
|
|
|
105
109
|
const texts = new Map();
|
|
106
110
|
// A file that has moved is read where it still stands: nothing moves until the plan is made.
|
|
107
111
|
const origins = new Map();
|
|
112
|
+
// A file that is made is written once, with what every step leaves in it.
|
|
113
|
+
const creates = new Map();
|
|
108
114
|
const planned = [];
|
|
109
115
|
for (const step of steps) {
|
|
116
|
+
if (step.kind === 'create') {
|
|
117
|
+
const made = await planCreate(step.file, host, texts);
|
|
118
|
+
if (typeof made === 'string') {
|
|
119
|
+
return made;
|
|
120
|
+
}
|
|
121
|
+
if (made !== null) {
|
|
122
|
+
creates.set(step.file.uri, made);
|
|
123
|
+
planned.push(made);
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
110
127
|
if (step.kind === 'rename') {
|
|
111
128
|
const oldPath = fileUriToPath(step.oldUri);
|
|
112
129
|
const newPath = fileUriToPath(step.newUri);
|
|
@@ -118,6 +135,16 @@ async function plan(steps, host) {
|
|
|
118
135
|
continue;
|
|
119
136
|
}
|
|
120
137
|
const source = origins.get(step.uri) ?? step.uri;
|
|
138
|
+
const created = creates.get(source);
|
|
139
|
+
if (created !== undefined) {
|
|
140
|
+
try {
|
|
141
|
+
created.text = applyTextEdits(created.text, step.edits);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return error instanceof Error ? error.message : String(error);
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
121
148
|
if (!texts.has(source)) {
|
|
122
149
|
const editor = host.editorOf(source);
|
|
123
150
|
if (editor) {
|
|
@@ -145,3 +172,15 @@ async function plan(steps, host) {
|
|
|
145
172
|
}
|
|
146
173
|
return planned;
|
|
147
174
|
}
|
|
175
|
+
/* A file that is not there yet, or null when it is and the create says to leave it. Making one over a file that is there is not supported. */
|
|
176
|
+
async function planCreate(file, host, texts) {
|
|
177
|
+
const path = fileUriToPath(file.uri);
|
|
178
|
+
if (path === null || host.files?.create === undefined) {
|
|
179
|
+
return `Creating ${file.uri} is not supported here`;
|
|
180
|
+
}
|
|
181
|
+
const there = texts.has(file.uri) || host.editorOf(file.uri) !== undefined || (await host.files.read(path)) !== null;
|
|
182
|
+
if (!there) {
|
|
183
|
+
return { kind: 'create', path, text: '' };
|
|
184
|
+
}
|
|
185
|
+
return file.options?.ignoreIfExists === true && file.options.overwrite !== true ? null : `${path} already exists`;
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/editor-react",
|
|
3
|
-
"version": "0.17.0
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Language-enabled editors, feature coordination and React popups over injected language services.",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"test": "bun test --conditions=source"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@adecore/editor": "0.17.0
|
|
61
|
-
"@adecore/lsp": "0.17.0
|
|
62
|
-
"@adecore/merge": "0.17.0
|
|
60
|
+
"@adecore/editor": "0.17.0",
|
|
61
|
+
"@adecore/lsp": "0.17.0",
|
|
62
|
+
"@adecore/merge": "0.17.0",
|
|
63
63
|
"zustand": "^5.0.15",
|
|
64
64
|
"lucide-react": "^1.47.0",
|
|
65
65
|
"react-markdown": "^10.1.0",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"remark-breaks": "^4.0.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@adecore/ui": "^0.17.0
|
|
70
|
+
"@adecore/ui": "^0.17.0",
|
|
71
71
|
"react": "^19.3.0",
|
|
72
72
|
"react-dom": "^19.3.0",
|
|
73
73
|
"i18next": "^26.4.2",
|
package/src/HoverCard.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next';
|
|
2
2
|
import { CircleX, Info, TriangleAlert } from 'lucide-react';
|
|
3
|
-
import { fileUriToPath } from '@adecore/lsp';
|
|
3
|
+
import { fileUriToPath, type Location } from '@adecore/lsp';
|
|
4
4
|
import { Button, Icon, Tooltip } from '@adecore/ui';
|
|
5
5
|
import { formatNumber } from '@adecore/ui/format';
|
|
6
6
|
import type { EditorPosition } from '@adecore/editor';
|
|
@@ -13,6 +13,28 @@ import { SymbolSections } from './HoverSections.tsx';
|
|
|
13
13
|
const SEVERITY_ICONS = { error: CircleX, warning: TriangleAlert, info: Info, hint: Info } as const;
|
|
14
14
|
const SEVERITY_COLORS = { error: 'text-status-error', warning: 'text-status-needs-you', info: 'text-status-running', hint: 'text-text-muted' } as const;
|
|
15
15
|
|
|
16
|
+
/*
|
|
17
|
+
* Where a definition is: only its line in the file of the card, else the file and the line, with a long name cut
|
|
18
|
+
* short before the line. A definition outside the project folder, such as the stubs of a standard library, has none.
|
|
19
|
+
*/
|
|
20
|
+
export function DefinitionPlace({ definition, uri, folder }: { definition: Location; uri: string; folder: string }) {
|
|
21
|
+
const { t } = useTranslation('editor');
|
|
22
|
+
const path = fileUriToPath(definition.uri);
|
|
23
|
+
const line = formatNumber(definition.range.start.line + 1);
|
|
24
|
+
if (path === null || (folder !== '' && !path.startsWith(`${folder}/`))) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (definition.uri === uri) {
|
|
28
|
+
return <span className="text-text-faint">{t('language.hover.line', { line })}</span>;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<span className="flex min-w-0 font-mono text-text-faint">
|
|
32
|
+
<span className="truncate">{basenameOf(path)}</span>
|
|
33
|
+
<span className="shrink-0">:{line}</span>
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
function ProblemSection({ problem, language }: { problem: Problem; language: EditorLanguage }) {
|
|
17
39
|
const { t } = useTranslation('editor');
|
|
18
40
|
const { diagnostic } = problem;
|
|
@@ -75,7 +97,6 @@ export function HoverCard({
|
|
|
75
97
|
position: EditorPosition;
|
|
76
98
|
}) {
|
|
77
99
|
const { t } = useTranslation('editor');
|
|
78
|
-
const place = info?.definition === null || info === null ? null : fileUriToPath(info.definition.uri);
|
|
79
100
|
return (
|
|
80
101
|
<div className="flex w-max min-w-[280px] max-w-[min(520px,calc(100vw-16px))] flex-col divide-y divide-border">
|
|
81
102
|
{info !== null && (
|
|
@@ -87,29 +108,27 @@ export function HoverCard({
|
|
|
87
108
|
void language.navigation.goToName(name, anchor, name === (declared ?? info.word) ? info.definition : null);
|
|
88
109
|
}}
|
|
89
110
|
/>
|
|
90
|
-
<div className="flex
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
</span>
|
|
112
|
-
)}
|
|
111
|
+
<div className="flex flex-col gap-0.5 px-3 py-1.5 text-xs">
|
|
112
|
+
<div className="flex items-center gap-3 whitespace-nowrap">
|
|
113
|
+
{info.definition !== null && (
|
|
114
|
+
<button type="button" className="text-accent hover:underline" onClick={() => language.goTo(info.definition!)}>
|
|
115
|
+
{t('language.hover.definition')}
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
{info.references !== null && info.references > 0 && (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className="text-accent hover:underline"
|
|
122
|
+
onClick={() => {
|
|
123
|
+
language.hover.hide();
|
|
124
|
+
void language.peek.open(position);
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) })}
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
{info.definition !== null && <DefinitionPlace definition={info.definition} uri={language.uri} folder={language.project.folder} />}
|
|
113
132
|
</div>
|
|
114
133
|
</>
|
|
115
134
|
)}
|
|
@@ -107,7 +107,7 @@ function linesOf(text: string): string[] {
|
|
|
107
107
|
return text.split(/\r\n|\r|\n/);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/* Null when the edit changes nothing, or
|
|
110
|
+
/* Null when the edit changes nothing, or deletes a file, which a preview of lines cannot show. A file it creates counts as another file. */
|
|
111
111
|
export function previewOf(text: string, edit: WorkspaceEdit, uri: string): EditPreview | null {
|
|
112
112
|
const entries = entriesOf(edit);
|
|
113
113
|
if (entries === null) {
|
package/src/folding.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EditorFoldHints,
|
|
1
|
+
import type { EditorFoldHints, EditorFoldRangeHint, EditorFoldSymbol } from '@adecore/editor';
|
|
2
2
|
import type { DocumentSymbol, DocumentSymbolResult, FoldingRange, SymbolInformation } from '@adecore/lsp';
|
|
3
3
|
import type { EditorLanguage } from './editor-language.ts';
|
|
4
4
|
import { Refresher } from './refresher.ts';
|
|
@@ -51,11 +51,17 @@ export function foldSymbolsOf(result: DocumentSymbolResult): EditorFoldSymbol[]
|
|
|
51
51
|
return found;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/* The ranges a server folds that span more than one line. */
|
|
55
|
-
export function foldRangesOf(ranges: readonly FoldingRange[] | null):
|
|
54
|
+
/* The ranges a server folds that span more than one line, with where a range starts and ends within its lines when it says. */
|
|
55
|
+
export function foldRangesOf(ranges: readonly FoldingRange[] | null): EditorFoldRangeHint[] {
|
|
56
56
|
return (ranges ?? [])
|
|
57
57
|
.filter((range) => range.endLine > range.startLine)
|
|
58
|
-
.map((range) => ({
|
|
58
|
+
.map((range) => ({
|
|
59
|
+
startLine: range.startLine,
|
|
60
|
+
endLine: range.endLine,
|
|
61
|
+
...(range.kind === undefined ? {} : { kind: range.kind }),
|
|
62
|
+
...(range.startCharacter === undefined ? {} : { startCharacter: range.startCharacter }),
|
|
63
|
+
...(range.endCharacter === undefined ? {} : { endCharacter: range.endCharacter })
|
|
64
|
+
}));
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
/*
|
|
@@ -65,7 +71,7 @@ export function foldRangesOf(ranges: readonly FoldingRange[] | null): (EditorFol
|
|
|
65
71
|
*/
|
|
66
72
|
export class FoldingFeature {
|
|
67
73
|
private symbols: EditorFoldSymbol[] = [];
|
|
68
|
-
private ranges:
|
|
74
|
+
private ranges: EditorFoldRangeHint[] = [];
|
|
69
75
|
private readonly refresher: Refresher;
|
|
70
76
|
private readonly language: EditorLanguage;
|
|
71
77
|
|
package/src/language-ids.ts
CHANGED
|
@@ -15,7 +15,8 @@ const LSP_IDS: Record<string, string> = {
|
|
|
15
15
|
yaml: 'yaml',
|
|
16
16
|
python: 'python',
|
|
17
17
|
shellscript: 'shellscript',
|
|
18
|
-
docker: 'dockerfile'
|
|
18
|
+
docker: 'dockerfile',
|
|
19
|
+
twig: 'twig'
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
/* The LSP language id of a file, or null when no language server here knows its language. */
|
|
@@ -45,7 +46,8 @@ const PATH_IDS: Record<string, string> = {
|
|
|
45
46
|
css: 'css',
|
|
46
47
|
scss: 'scss',
|
|
47
48
|
html: 'html',
|
|
48
|
-
md: 'markdown'
|
|
49
|
+
md: 'markdown',
|
|
50
|
+
twig: 'twig'
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
/* The highlighter id for the code of a file we only know by its path, such as a place another file refers to. */
|
package/src/locales/en.json
CHANGED
package/src/locales/nl.json
CHANGED
package/src/workspace-edit.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { Editor, EditorContentChange } from '@adecore/editor';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyTextEdits,
|
|
4
|
+
fileUriToPath,
|
|
5
|
+
type ApplyWorkspaceEditResult,
|
|
6
|
+
type CreateFile,
|
|
7
|
+
type RenameFile,
|
|
8
|
+
type TextEdit,
|
|
9
|
+
type WorkspaceEdit
|
|
10
|
+
} from '@adecore/lsp';
|
|
3
11
|
import type { DiskText } from './host-types.ts';
|
|
4
12
|
|
|
5
13
|
/* A file that changed without an editor of this project holding it: its new text waits as an unsaved draft. */
|
|
@@ -18,6 +26,8 @@ export interface ProjectFiles {
|
|
|
18
26
|
save(files: readonly StagedFile[]): Promise<string | null>;
|
|
19
27
|
/* Moves a file or folder on the machine, with what is open on it, and tells the servers it moved; the reason it could not, or null. */
|
|
20
28
|
rename(from: string, to: string): Promise<string | null>;
|
|
29
|
+
/* Makes a file that is not there with its text, through the machine; the reason it could not, or null. Without it no edit creates a file. */
|
|
30
|
+
create?(path: string, text: string): Promise<string | null>;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export interface WorkspaceEditHost {
|
|
@@ -30,17 +40,20 @@ function refused(failureReason: string): ApplyWorkspaceEditResult {
|
|
|
30
40
|
return { applied: false, failureReason };
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
/* One thing an edit does, in the order it says: text edits of a document, or a file that moves. */
|
|
43
|
+
/* One thing an edit does, in the order it says: text edits of a document, a file that is made, or a file that moves. */
|
|
34
44
|
type Step =
|
|
35
45
|
| { readonly kind: 'edits'; readonly uri: string; readonly edits: TextEdit[] }
|
|
46
|
+
| { readonly kind: 'create'; readonly file: CreateFile }
|
|
36
47
|
| { readonly kind: 'rename'; readonly oldUri: string; readonly newUri: string };
|
|
37
48
|
|
|
38
|
-
/* The steps of an edit; null when it
|
|
49
|
+
/* The steps of an edit; null when it deletes a file, which an edit is not applied with. */
|
|
39
50
|
function stepsOf(edit: WorkspaceEdit): Step[] | null {
|
|
40
51
|
const steps: Step[] = [];
|
|
41
52
|
for (const change of edit.documentChanges ?? []) {
|
|
42
53
|
if ('textDocument' in change) {
|
|
43
54
|
steps.push({ kind: 'edits', uri: change.textDocument.uri, edits: change.edits });
|
|
55
|
+
} else if (change.kind === 'create') {
|
|
56
|
+
steps.push({ kind: 'create', file: change });
|
|
44
57
|
} else if (change.kind === 'rename') {
|
|
45
58
|
steps.push({ kind: 'rename', oldUri: change.oldUri, newUri: change.newUri });
|
|
46
59
|
} else {
|
|
@@ -55,7 +68,7 @@ function stepsOf(edit: WorkspaceEdit): Step[] | null {
|
|
|
55
68
|
return steps;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
|
-
/* The text edits of an edit per document, as the entries a document has in it; null when it
|
|
71
|
+
/* The text edits of an edit per document, as the entries a document has in it; null when it deletes a file. */
|
|
59
72
|
export function entriesOf(edit: WorkspaceEdit): Map<string, TextEdit[][]> | null {
|
|
60
73
|
const steps = stepsOf(edit);
|
|
61
74
|
if (steps === null) {
|
|
@@ -90,19 +103,27 @@ interface PlannedEdits {
|
|
|
90
103
|
readonly disk: DiskText | null;
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
|
|
106
|
+
/* A file to make, with the text every step of the edit leaves in it. */
|
|
107
|
+
interface PlannedCreate {
|
|
108
|
+
readonly kind: 'create';
|
|
109
|
+
readonly path: string;
|
|
110
|
+
text: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Planned = PlannedEdits | PlannedCreate | { readonly kind: 'rename'; readonly oldPath: string; readonly newPath: string };
|
|
94
114
|
|
|
95
115
|
/*
|
|
96
116
|
* Applies an LSP workspace edit on the client, step by step in the order it says. A file an editor holds takes
|
|
97
117
|
* its edits as one undo step. Any other file gets its new text as an unsaved draft that a person saves, and
|
|
98
118
|
* nothing is written to disk on a server's word alone, unless the edit also moves a file: the edits and the
|
|
99
|
-
* move are then one change, so every file is saved through the machine and the move is made there.
|
|
100
|
-
* edit is
|
|
119
|
+
* move are then one change, so every file is saved through the machine and the move is made there. A file
|
|
120
|
+
* the edit creates is made through the machine with the text the edit writes into it. The edit is checked
|
|
121
|
+
* against every text first, so one that does not fit changes nothing.
|
|
101
122
|
*/
|
|
102
123
|
export async function applyWorkspaceEdit(edit: WorkspaceEdit, host: WorkspaceEditHost): Promise<ApplyWorkspaceEditResult> {
|
|
103
124
|
const steps = stepsOf(edit);
|
|
104
125
|
if (steps === null) {
|
|
105
|
-
return refused('
|
|
126
|
+
return refused('Deleting files is not supported yet');
|
|
106
127
|
}
|
|
107
128
|
const planned = await plan(steps, host);
|
|
108
129
|
if (typeof planned === 'string') {
|
|
@@ -111,8 +132,8 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, host: WorkspaceEdi
|
|
|
111
132
|
const moving = planned.some((item) => item.kind === 'rename');
|
|
112
133
|
const staged: StagedFile[] = [];
|
|
113
134
|
for (const item of planned) {
|
|
114
|
-
if (item.kind === 'rename') {
|
|
115
|
-
const reason = await host.files!.rename(item.oldPath, item.newPath);
|
|
135
|
+
if (item.kind === 'rename' || item.kind === 'create') {
|
|
136
|
+
const reason = item.kind === 'rename' ? await host.files!.rename(item.oldPath, item.newPath) : await host.files!.create!(item.path, item.text);
|
|
116
137
|
if (reason !== null) {
|
|
117
138
|
return refused(reason);
|
|
118
139
|
}
|
|
@@ -154,8 +175,21 @@ async function plan(steps: readonly Step[], host: WorkspaceEditHost): Promise<Pl
|
|
|
154
175
|
const texts = new Map<string, { text: string; disk: DiskText | null }>();
|
|
155
176
|
// A file that has moved is read where it still stands: nothing moves until the plan is made.
|
|
156
177
|
const origins = new Map<string, string>();
|
|
178
|
+
// A file that is made is written once, with what every step leaves in it.
|
|
179
|
+
const creates = new Map<string, PlannedCreate>();
|
|
157
180
|
const planned: Planned[] = [];
|
|
158
181
|
for (const step of steps) {
|
|
182
|
+
if (step.kind === 'create') {
|
|
183
|
+
const made = await planCreate(step.file, host, texts);
|
|
184
|
+
if (typeof made === 'string') {
|
|
185
|
+
return made;
|
|
186
|
+
}
|
|
187
|
+
if (made !== null) {
|
|
188
|
+
creates.set(step.file.uri, made);
|
|
189
|
+
planned.push(made);
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
159
193
|
if (step.kind === 'rename') {
|
|
160
194
|
const oldPath = fileUriToPath(step.oldUri);
|
|
161
195
|
const newPath = fileUriToPath(step.newUri);
|
|
@@ -167,6 +201,15 @@ async function plan(steps: readonly Step[], host: WorkspaceEditHost): Promise<Pl
|
|
|
167
201
|
continue;
|
|
168
202
|
}
|
|
169
203
|
const source = origins.get(step.uri) ?? step.uri;
|
|
204
|
+
const created = creates.get(source);
|
|
205
|
+
if (created !== undefined) {
|
|
206
|
+
try {
|
|
207
|
+
created.text = applyTextEdits(created.text, step.edits);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return error instanceof Error ? error.message : String(error);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
170
213
|
if (!texts.has(source)) {
|
|
171
214
|
const editor = host.editorOf(source);
|
|
172
215
|
if (editor) {
|
|
@@ -192,3 +235,20 @@ async function plan(steps: readonly Step[], host: WorkspaceEditHost): Promise<Pl
|
|
|
192
235
|
}
|
|
193
236
|
return planned;
|
|
194
237
|
}
|
|
238
|
+
|
|
239
|
+
/* A file that is not there yet, or null when it is and the create says to leave it. Making one over a file that is there is not supported. */
|
|
240
|
+
async function planCreate(
|
|
241
|
+
file: CreateFile,
|
|
242
|
+
host: WorkspaceEditHost,
|
|
243
|
+
texts: ReadonlyMap<string, { text: string; disk: DiskText | null }>
|
|
244
|
+
): Promise<PlannedCreate | null | string> {
|
|
245
|
+
const path = fileUriToPath(file.uri);
|
|
246
|
+
if (path === null || host.files?.create === undefined) {
|
|
247
|
+
return `Creating ${file.uri} is not supported here`;
|
|
248
|
+
}
|
|
249
|
+
const there = texts.has(file.uri) || host.editorOf(file.uri) !== undefined || (await host.files.read(path)) !== null;
|
|
250
|
+
if (!there) {
|
|
251
|
+
return { kind: 'create', path, text: '' };
|
|
252
|
+
}
|
|
253
|
+
return file.options?.ignoreIfExists === true && file.options.overwrite !== true ? null : `${path} already exists`;
|
|
254
|
+
}
|