@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.
@@ -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 items-center gap-3 px-3 py-1.5 text-xs", 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: () => {
35
- language.hover.hide();
36
- void language.peek.open(position);
37
- }, children: t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) }) })), place !== null && info.definition !== null && (_jsxs("span", { className: "ml-auto font-mono text-text-faint", children: [basenameOf(place), ":", formatNumber(info.definition.range.start.line + 1)] }))] })] })), problems.map((problem, index) => (_jsx(ProblemSection, { problem: problem, language: language }, index)))] }));
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 creates or deletes a file, which a preview of lines cannot show. */
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 { EditorFoldRange, EditorFoldSymbol } from '@adecore/editor';
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): (EditorFoldRange & {
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) => ({ startLine: range.startLine, endLine: range.endLine, ...(range.kind === undefined ? {} : { kind: range.kind }) }));
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
@@ -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) {
@@ -6,6 +6,7 @@
6
6
  "definition": "Go to definition",
7
7
  "references_one": "{{formatted}} reference",
8
8
  "references_other": "{{formatted}} references",
9
+ "line": "Line {{line}}",
9
10
  "none": "No information for this position"
10
11
  },
11
12
  "edit": {
@@ -6,6 +6,7 @@
6
6
  "definition": "Ga naar definitie",
7
7
  "references_one": "{{formatted}} referentie",
8
8
  "references_other": "{{formatted}} referenties",
9
+ "line": "Regel {{line}}",
9
10
  "none": "Geen informatie voor deze plek"
10
11
  },
11
12
  "edit": {
@@ -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;
@@ -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 creates or deletes a file, which an edit is not applied with. */
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 creates or deletes a file. */
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. The
52
- * edit is checked against every text first, so one that does not fit changes nothing.
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('Creating and deleting files is not supported yet');
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-beta.2",
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-beta.2",
61
- "@adecore/lsp": "0.17.0-beta.2",
62
- "@adecore/merge": "0.17.0-beta.2",
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-beta.2",
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 items-center gap-3 px-3 py-1.5 text-xs">
91
- {info.definition !== null && (
92
- <button type="button" className="text-accent hover:underline" onClick={() => language.goTo(info.definition!)}>
93
- {t('language.hover.definition')}
94
- </button>
95
- )}
96
- {info.references !== null && info.references > 0 && (
97
- <button
98
- type="button"
99
- className="text-accent hover:underline"
100
- onClick={() => {
101
- language.hover.hide();
102
- void language.peek.open(position);
103
- }}
104
- >
105
- {t('language.hover.references', { count: info.references, formatted: formatNumber(info.references) })}
106
- </button>
107
- )}
108
- {place !== null && info.definition !== null && (
109
- <span className="ml-auto font-mono text-text-faint">
110
- {basenameOf(place)}:{formatNumber(info.definition.range.start.line + 1)}
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 creates or deletes a file, which a preview of lines cannot show. */
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, EditorFoldRange, EditorFoldSymbol } from '@adecore/editor';
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): (EditorFoldRange & { kind?: string })[] {
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) => ({ startLine: range.startLine, endLine: range.endLine, ...(range.kind === undefined ? {} : { kind: range.kind }) }));
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: (EditorFoldRange & { kind?: string })[] = [];
74
+ private ranges: EditorFoldRangeHint[] = [];
69
75
  private readonly refresher: Refresher;
70
76
  private readonly language: EditorLanguage;
71
77
 
@@ -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. */
@@ -6,6 +6,7 @@
6
6
  "definition": "Go to definition",
7
7
  "references_one": "{{formatted}} reference",
8
8
  "references_other": "{{formatted}} references",
9
+ "line": "Line {{line}}",
9
10
  "none": "No information for this position"
10
11
  },
11
12
  "edit": {
@@ -6,6 +6,7 @@
6
6
  "definition": "Ga naar definitie",
7
7
  "references_one": "{{formatted}} referentie",
8
8
  "references_other": "{{formatted}} referenties",
9
+ "line": "Regel {{line}}",
9
10
  "none": "Geen informatie voor deze plek"
10
11
  },
11
12
  "edit": {
@@ -1,5 +1,13 @@
1
1
  import type { Editor, EditorContentChange } from '@adecore/editor';
2
- import { applyTextEdits, fileUriToPath, type ApplyWorkspaceEditResult, type RenameFile, type TextEdit, type WorkspaceEdit } from '@adecore/lsp';
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 creates or deletes a file, which an edit is not applied with. */
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 creates or deletes a file. */
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
- type Planned = PlannedEdits | { readonly kind: 'rename'; readonly oldPath: string; readonly newPath: string };
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. The
100
- * edit is checked against every text first, so one that does not fit changes nothing.
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('Creating and deleting files is not supported yet');
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
+ }