@reviewflow/ui 0.0.0-stage → 6.22.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/LICENSE +15 -0
- package/README.md +95 -2
- package/package.json +101 -3
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- package/src/slots.tsx +34 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import type { EditorState, Range } from "@codemirror/state";
|
|
3
|
+
import { Prec } from "@codemirror/state";
|
|
4
|
+
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
|
|
5
|
+
import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";
|
|
6
|
+
import type { SyntaxNode } from "@lezer/common";
|
|
7
|
+
import { alertHeaderMarks, alertLinesOfBlockquote } from "./alerts.ts";
|
|
8
|
+
import { nodeClassEntry } from "./nodeClasses.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The markdown look: a walk of the syntax tree over the visible ranges that
|
|
12
|
+
* turns nodes into three decoration sets — lines, construct marks and
|
|
13
|
+
* delimiter marks (see `nodeClasses.ts` for why three). **Only marks and line
|
|
14
|
+
* classes, never a replace or a widget**: the markdown stays visible and is
|
|
15
|
+
* edited as the text it is, so what is saved is byte for byte what was typed
|
|
16
|
+
* and a file under git takes no diff it did not ask for.
|
|
17
|
+
*
|
|
18
|
+
* Ported from vscode-extension-markdown-beautiful-editor
|
|
19
|
+
* (`src/webview/cm/decorations.ts`), whose header documents each special case.
|
|
20
|
+
*/
|
|
21
|
+
export interface MarkdownDecorationSets {
|
|
22
|
+
lines: DecorationSet;
|
|
23
|
+
marks: DecorationSet;
|
|
24
|
+
syntax: DecorationSet;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type TableColumnAlign = "center" | "left" | "none" | "right";
|
|
28
|
+
|
|
29
|
+
const alignOfDelimiterCell = (cell: string): TableColumnAlign => {
|
|
30
|
+
const trimmed = cell.trim();
|
|
31
|
+
const left = trimmed.startsWith(":");
|
|
32
|
+
const right = trimmed.endsWith(":");
|
|
33
|
+
if (left && right) return "center";
|
|
34
|
+
if (right) return "right";
|
|
35
|
+
if (left) return "left";
|
|
36
|
+
return "none";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** `|:--|:-:|` → one alignment per column; the edge pipes leave empty cells */
|
|
40
|
+
const tableColumnAligns = (delimiterRow: string): TableColumnAlign[] =>
|
|
41
|
+
delimiterRow.split("|").slice(1, -1).map(alignOfDelimiterCell);
|
|
42
|
+
|
|
43
|
+
const blockquoteDepth = (node: SyntaxNode): number => {
|
|
44
|
+
let depth = 0;
|
|
45
|
+
for (
|
|
46
|
+
let current: SyntaxNode | null = node;
|
|
47
|
+
current;
|
|
48
|
+
current = current.parent
|
|
49
|
+
) {
|
|
50
|
+
if (current.name === "Blockquote") depth += 1;
|
|
51
|
+
}
|
|
52
|
+
return depth;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `Decoration.mark().range()` throws on an empty range, and a throw here takes
|
|
57
|
+
* the whole plugin down: CodeMirror deactivates it and every class in the
|
|
58
|
+
* document disappears. `` reaches here empty from ordinary text.
|
|
59
|
+
*/
|
|
60
|
+
const pushMark = (
|
|
61
|
+
target: Range<Decoration>[],
|
|
62
|
+
className: string,
|
|
63
|
+
from: number,
|
|
64
|
+
to: number,
|
|
65
|
+
): void => {
|
|
66
|
+
if (to > from) {
|
|
67
|
+
target.push(Decoration.mark({ class: className }).range(from, to));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isSetextHeading = (name: string): boolean =>
|
|
72
|
+
name === "SetextHeading1" || name === "SetextHeading2";
|
|
73
|
+
|
|
74
|
+
const isCheckedTask = (state: EditorState, task: SyntaxNode): boolean => {
|
|
75
|
+
const marker = task.getChild("TaskMarker");
|
|
76
|
+
return (
|
|
77
|
+
marker !== null && /x/iu.test(state.doc.sliceString(marker.from, marker.to))
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** the nearest enclosing task decides: an unchecked sub-task stops a checked parent */
|
|
82
|
+
const isInsideCheckedTask = (state: EditorState, node: SyntaxNode): boolean => {
|
|
83
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
84
|
+
const task = current.name === "ListItem" ? current.getChild("Task") : null;
|
|
85
|
+
if (task !== null) return isCheckedTask(state, task);
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const buildMarkdownDecorations = (
|
|
91
|
+
state: EditorState,
|
|
92
|
+
ranges: readonly { from: number; to: number }[],
|
|
93
|
+
): MarkdownDecorationSets => {
|
|
94
|
+
const marks: Range<Decoration>[] = [];
|
|
95
|
+
const syntax: Range<Decoration>[] = [];
|
|
96
|
+
// keyed by line start, so a line touched by several nodes gets one decoration
|
|
97
|
+
const lineClasses = new Map<number, Set<string>>();
|
|
98
|
+
|
|
99
|
+
const addLineClasses = (
|
|
100
|
+
from: number,
|
|
101
|
+
to: number,
|
|
102
|
+
className: string,
|
|
103
|
+
): void => {
|
|
104
|
+
let position = from;
|
|
105
|
+
while (position <= to) {
|
|
106
|
+
const line = state.doc.lineAt(position);
|
|
107
|
+
const classes = lineClasses.get(line.from) ?? new Set<string>();
|
|
108
|
+
classes.add(className);
|
|
109
|
+
lineClasses.set(line.from, classes);
|
|
110
|
+
position = line.to + 1;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const addLineClassAt = (position: number, className: string): void => {
|
|
114
|
+
addLineClasses(position, position, className);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const lastLineOf = (node: { from: number; to: number }): number =>
|
|
118
|
+
state.doc.lineAt(Math.max(node.from, node.to - 1)).from;
|
|
119
|
+
|
|
120
|
+
const addBlockBoundaries = (node: SyntaxNode, prefix: string): void => {
|
|
121
|
+
addLineClassAt(state.doc.lineAt(node.from).from, `${prefix}-first`);
|
|
122
|
+
addLineClassAt(lastLineOf(node), `${prefix}-last`);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const handleBlockquote = (node: SyntaxNode): void => {
|
|
126
|
+
const depth = Math.min(blockquoteDepth(node), 3);
|
|
127
|
+
addLineClasses(node.from, node.to, `md-quote-${String(depth)}`);
|
|
128
|
+
if (depth === 1) addBlockBoundaries(node, "blockquote");
|
|
129
|
+
for (const line of alertLinesOfBlockquote(state, node)) {
|
|
130
|
+
for (const className of line.classes) {
|
|
131
|
+
addLineClassAt(line.from, className);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const mark of alertHeaderMarks(state, node)) {
|
|
135
|
+
pushMark(
|
|
136
|
+
mark.className === "md-syntax" ? syntax : marks,
|
|
137
|
+
mark.className,
|
|
138
|
+
mark.from,
|
|
139
|
+
mark.to,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleSetextHeading = (node: SyntaxNode): void => {
|
|
145
|
+
const underline = lastLineOf(node);
|
|
146
|
+
addLineClasses(
|
|
147
|
+
node.from,
|
|
148
|
+
Math.max(node.from, underline - 1),
|
|
149
|
+
"md-setext-title",
|
|
150
|
+
);
|
|
151
|
+
addLineClassAt(underline, "md-setext-underline");
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `text-decoration` cannot be undone on a child, so it goes on the text
|
|
156
|
+
* between the `~~` rather than on the construct.
|
|
157
|
+
*/
|
|
158
|
+
const handleStrikethrough = (node: SyntaxNode): void => {
|
|
159
|
+
const [open, close] = node.getChildren("StrikethroughMark");
|
|
160
|
+
if (open !== undefined && close !== undefined) {
|
|
161
|
+
pushMark(marks, "md-strike-text", open.to, close.from);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleLink = (node: SyntaxNode): void => {
|
|
166
|
+
const [open, close] = node.getChildren("LinkMark");
|
|
167
|
+
if (open !== undefined && close !== undefined) {
|
|
168
|
+
pushMark(marks, "md-text", open.to, close.from);
|
|
169
|
+
}
|
|
170
|
+
if (node.getChild("LinkLabel") !== null) {
|
|
171
|
+
pushMark(marks, "md-ref-link", node.from, node.to);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const handleImage = (node: SyntaxNode): void => {
|
|
176
|
+
const [open, close] = node.getChildren("LinkMark");
|
|
177
|
+
if (open !== undefined && close !== undefined) {
|
|
178
|
+
pushMark(syntax, "md-alt", open.to, close.from);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** unlike other bracketed nodes, a `LinkLabel`'s range includes its brackets */
|
|
183
|
+
const handleLinkLabel = (node: SyntaxNode): void => {
|
|
184
|
+
pushMark(syntax, "md-syntax", node.from, node.from + 1);
|
|
185
|
+
pushMark(syntax, "md-syntax", node.to - 1, node.to);
|
|
186
|
+
pushMark(marks, "md-ref", node.from + 1, node.to - 1);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleLinkTitle = (node: SyntaxNode): void => {
|
|
190
|
+
pushMark(
|
|
191
|
+
marks,
|
|
192
|
+
node.parent?.name === "LinkReference"
|
|
193
|
+
? "md-link-def-title"
|
|
194
|
+
: "md-link-title",
|
|
195
|
+
node.from,
|
|
196
|
+
node.to,
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const handleTask = (node: SyntaxNode): void => {
|
|
201
|
+
pushMark(
|
|
202
|
+
marks,
|
|
203
|
+
isCheckedTask(state, node)
|
|
204
|
+
? "md-task md-task-checked"
|
|
205
|
+
: "md-task md-task-unchecked",
|
|
206
|
+
node.from,
|
|
207
|
+
node.to,
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** a checked task's `Task` node stops at its first paragraph; its children are still done */
|
|
212
|
+
const handleParagraph = (node: SyntaxNode): void => {
|
|
213
|
+
if (isInsideCheckedTask(state, node)) {
|
|
214
|
+
pushMark(marks, "md-task-parent-checked", node.from, node.to);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `TableDelimiter` is a lone `|` inside a row, but the whole `|:--|` row when
|
|
220
|
+
* it is the table's own child — one node name, two jobs. The alignment is
|
|
221
|
+
* not a node at all and is read back off that row's text. The walk still
|
|
222
|
+
* descends: a cell's content is inline markdown.
|
|
223
|
+
*/
|
|
224
|
+
const handleTable = (node: SyntaxNode): void => {
|
|
225
|
+
const delimiterRow = node.getChild("TableDelimiter");
|
|
226
|
+
const aligns =
|
|
227
|
+
delimiterRow === null
|
|
228
|
+
? []
|
|
229
|
+
: tableColumnAligns(
|
|
230
|
+
state.doc.sliceString(delimiterRow.from, delimiterRow.to),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const emitRowCells = (row: SyntaxNode): void => {
|
|
234
|
+
let column = 0;
|
|
235
|
+
for (let child = row.firstChild; child; child = child.nextSibling) {
|
|
236
|
+
if (child.name === "TableDelimiter") {
|
|
237
|
+
pushMark(syntax, "md-syntax", child.from, child.to);
|
|
238
|
+
} else if (child.name === "TableCell") {
|
|
239
|
+
const align = aligns[column] ?? "none";
|
|
240
|
+
pushMark(
|
|
241
|
+
marks,
|
|
242
|
+
`md-table-cell md-col-${align}`,
|
|
243
|
+
child.from,
|
|
244
|
+
child.to,
|
|
245
|
+
);
|
|
246
|
+
column += 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
for (let row = node.firstChild; row; row = row.nextSibling) {
|
|
252
|
+
if (row.name === "TableHeader" || row.name === "TableRow") {
|
|
253
|
+
emitRowCells(row);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (delimiterRow === null) return;
|
|
258
|
+
const text = state.doc.sliceString(delimiterRow.from, delimiterRow.to);
|
|
259
|
+
let cellStart = -1;
|
|
260
|
+
let column = 0;
|
|
261
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
262
|
+
if (text[index] !== "|") continue;
|
|
263
|
+
const at = delimiterRow.from + index;
|
|
264
|
+
pushMark(syntax, "md-syntax", at, at + 1);
|
|
265
|
+
if (cellStart >= 0) {
|
|
266
|
+
const align = aligns[column] ?? "none";
|
|
267
|
+
pushMark(
|
|
268
|
+
marks,
|
|
269
|
+
`md-table-cell md-table-sep md-col-${align}`,
|
|
270
|
+
delimiterRow.from + cellStart,
|
|
271
|
+
at,
|
|
272
|
+
);
|
|
273
|
+
column += 1;
|
|
274
|
+
}
|
|
275
|
+
cellStart = index + 1;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const tree = syntaxTree(state);
|
|
280
|
+
for (const { from, to } of ranges) {
|
|
281
|
+
tree.iterate({
|
|
282
|
+
from,
|
|
283
|
+
to,
|
|
284
|
+
enter(ref) {
|
|
285
|
+
// another language's tokens — a fence's code, the yaml mounted over
|
|
286
|
+
// frontmatter — are never markdown constructs
|
|
287
|
+
if (ref.name === "CodeText" || ref.name === "FrontmatterContent") {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const entry = nodeClassEntry(ref.name);
|
|
292
|
+
if (entry?.layer === "mark") {
|
|
293
|
+
pushMark(marks, entry.className, ref.from, ref.to);
|
|
294
|
+
}
|
|
295
|
+
if (entry?.layer === "syntax") {
|
|
296
|
+
pushMark(syntax, entry.className, ref.from, ref.to);
|
|
297
|
+
}
|
|
298
|
+
if (entry?.layer === "line") {
|
|
299
|
+
const lineTo = isSetextHeading(ref.name)
|
|
300
|
+
? lastLineOf(ref) - 1
|
|
301
|
+
: ref.to;
|
|
302
|
+
addLineClasses(ref.from, Math.max(ref.from, lineTo), entry.className);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const node = ref.node;
|
|
306
|
+
switch (ref.name) {
|
|
307
|
+
case "Blockquote":
|
|
308
|
+
handleBlockquote(node);
|
|
309
|
+
break;
|
|
310
|
+
case "FencedCode":
|
|
311
|
+
addBlockBoundaries(node, "md-code-block");
|
|
312
|
+
break;
|
|
313
|
+
case "Frontmatter":
|
|
314
|
+
addBlockBoundaries(node, "md-frontmatter");
|
|
315
|
+
break;
|
|
316
|
+
case "SetextHeading1":
|
|
317
|
+
case "SetextHeading2":
|
|
318
|
+
handleSetextHeading(node);
|
|
319
|
+
break;
|
|
320
|
+
case "HorizontalRule":
|
|
321
|
+
pushMark(marks, "md-hr-text", ref.from, ref.to);
|
|
322
|
+
break;
|
|
323
|
+
case "Escape":
|
|
324
|
+
pushMark(syntax, "md-syntax", ref.from, ref.from + 1);
|
|
325
|
+
break;
|
|
326
|
+
case "Strikethrough":
|
|
327
|
+
handleStrikethrough(node);
|
|
328
|
+
break;
|
|
329
|
+
case "Link":
|
|
330
|
+
handleLink(node);
|
|
331
|
+
break;
|
|
332
|
+
case "Image":
|
|
333
|
+
handleImage(node);
|
|
334
|
+
break;
|
|
335
|
+
case "LinkLabel":
|
|
336
|
+
handleLinkLabel(node);
|
|
337
|
+
break;
|
|
338
|
+
case "LinkTitle":
|
|
339
|
+
handleLinkTitle(node);
|
|
340
|
+
break;
|
|
341
|
+
case "Task":
|
|
342
|
+
handleTask(node);
|
|
343
|
+
break;
|
|
344
|
+
case "Paragraph":
|
|
345
|
+
handleParagraph(node);
|
|
346
|
+
break;
|
|
347
|
+
case "Table":
|
|
348
|
+
handleTable(node);
|
|
349
|
+
break;
|
|
350
|
+
default:
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const lines = [...lineClasses.entries()].map(([from, classes]) =>
|
|
359
|
+
Decoration.line({ class: [...classes].join(" ") }).range(from),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
lines: Decoration.set(lines, true),
|
|
364
|
+
marks: Decoration.set(marks, true),
|
|
365
|
+
syntax: Decoration.set(syntax, true),
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const emptySets: MarkdownDecorationSets = {
|
|
370
|
+
lines: Decoration.none,
|
|
371
|
+
marks: Decoration.none,
|
|
372
|
+
syntax: Decoration.none,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A throw from a plugin's `update` deactivates it for the rest of the session,
|
|
377
|
+
* so a failed build keeps the previous sets — or none when the document
|
|
378
|
+
* changed, since ranges built against the old one can point past its end.
|
|
379
|
+
*/
|
|
380
|
+
const buildOrKeep = (
|
|
381
|
+
state: EditorState,
|
|
382
|
+
ranges: readonly { from: number; to: number }[],
|
|
383
|
+
previous: MarkdownDecorationSets,
|
|
384
|
+
): MarkdownDecorationSets => {
|
|
385
|
+
try {
|
|
386
|
+
return buildMarkdownDecorations(state, ranges);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error("markdown decorations build failed", error);
|
|
389
|
+
return previous;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const markdownDecorationsPlugin = ViewPlugin.define<{
|
|
394
|
+
sets: MarkdownDecorationSets;
|
|
395
|
+
update: (update: ViewUpdate) => void;
|
|
396
|
+
}>((view) => {
|
|
397
|
+
const plugin = {
|
|
398
|
+
sets: buildOrKeep(view.state, view.visibleRanges, emptySets),
|
|
399
|
+
update: (update: ViewUpdate): void => {
|
|
400
|
+
// the parser finishing a large document in the background changes the
|
|
401
|
+
// tree with neither flag set
|
|
402
|
+
const treeChanged =
|
|
403
|
+
syntaxTree(update.startState) !== syntaxTree(update.state);
|
|
404
|
+
if (!update.docChanged && !update.viewportChanged && !treeChanged) return;
|
|
405
|
+
plugin.sets = buildOrKeep(
|
|
406
|
+
update.state,
|
|
407
|
+
update.view.visibleRanges,
|
|
408
|
+
update.docChanged ? emptySets : plugin.sets,
|
|
409
|
+
);
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
return plugin;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const decorationSource =
|
|
416
|
+
(key: keyof MarkdownDecorationSets) =>
|
|
417
|
+
(view: EditorView): DecorationSet =>
|
|
418
|
+
view.plugin(markdownDecorationsPlugin)?.sets[key] ?? Decoration.none;
|
|
419
|
+
|
|
420
|
+
export const markdownDecorations = [
|
|
421
|
+
markdownDecorationsPlugin,
|
|
422
|
+
Prec.lowest(EditorView.decorations.of(decorationSource("lines"))),
|
|
423
|
+
Prec.lowest(EditorView.decorations.of(decorationSource("marks"))),
|
|
424
|
+
Prec.highest(EditorView.decorations.of(decorationSource("syntax"))),
|
|
425
|
+
];
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionButton,
|
|
3
|
+
Button,
|
|
4
|
+
Code,
|
|
5
|
+
ErrorMessage,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
WarningMessage,
|
|
9
|
+
} from "alouette";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
13
|
+
import { CodeEditor } from "./code-editor.tsx";
|
|
14
|
+
import { MarkdownEditor } from "./markdown-editor.tsx";
|
|
15
|
+
|
|
16
|
+
/** What `onSave` answers; the shapes `@reviewflow/sdk/files` produces, carried over a route. */
|
|
17
|
+
export type EditableFileSaveResult =
|
|
18
|
+
/** the file changed on disk since the editor opened it; `source` is what it holds now */
|
|
19
|
+
| { status: "conflict"; source: string }
|
|
20
|
+
/** shown as is; nothing was written */
|
|
21
|
+
| { status: "invalid"; error: string }
|
|
22
|
+
/** `source` is what is on disk now, which the editor then treats as saved */
|
|
23
|
+
| { status: "saved"; source: string; warnings: string[] };
|
|
24
|
+
|
|
25
|
+
export interface EditableFileProps {
|
|
26
|
+
/** what is on disk: the text the editor opens on, and what Save is checked against */
|
|
27
|
+
source: string;
|
|
28
|
+
/**
|
|
29
|
+
* Saves `source` unless the file no longer holds `expectedSource`. A thrown
|
|
30
|
+
* error (the server unreachable) is shown under the buttons.
|
|
31
|
+
*/
|
|
32
|
+
onSave: (
|
|
33
|
+
source: string,
|
|
34
|
+
expectedSource: string,
|
|
35
|
+
) => Promise<EditableFileSaveResult>;
|
|
36
|
+
language: "json" | "markdown";
|
|
37
|
+
"aria-label": string;
|
|
38
|
+
/** shown above the editor */
|
|
39
|
+
path?: string;
|
|
40
|
+
/** opens with this text instead of `source`, unsaved: a suggested edit to review */
|
|
41
|
+
initialValue?: string;
|
|
42
|
+
/** an offset in the opening text to put the cursor at */
|
|
43
|
+
cursor?: number | null;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Notice =
|
|
48
|
+
| { kind: "conflict"; source: string }
|
|
49
|
+
| { kind: "invalid"; error: string }
|
|
50
|
+
| { kind: "none" }
|
|
51
|
+
| { kind: "saved"; warnings: string[] };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One file edited as the text it is, with the three outcomes of a save told
|
|
55
|
+
* where they happen: a refusal is shown as the server worded it, warnings are
|
|
56
|
+
* listed under the editor (the file is saved), and a file that changed on disk
|
|
57
|
+
* offers to reload it (dropping the edits) or to keep editing (the next save
|
|
58
|
+
* then replaces what is on disk, knowingly). It knows nothing about routes: the
|
|
59
|
+
* caller's `onSave` does the call.
|
|
60
|
+
*
|
|
61
|
+
* `source` changing under a mounted editor with no unsaved edit is adopted; with
|
|
62
|
+
* unsaved edits it is a conflict, as a refused save would be: a refetch never
|
|
63
|
+
* eats what is being typed, and Save never overwrites the outside change
|
|
64
|
+
* unknowingly.
|
|
65
|
+
*/
|
|
66
|
+
export function EditableFile({
|
|
67
|
+
source,
|
|
68
|
+
onSave,
|
|
69
|
+
language,
|
|
70
|
+
"aria-label": ariaLabel,
|
|
71
|
+
path,
|
|
72
|
+
initialValue,
|
|
73
|
+
cursor = null,
|
|
74
|
+
className,
|
|
75
|
+
}: EditableFileProps): ReactNode {
|
|
76
|
+
const [lastSource, setLastSource] = useState(source);
|
|
77
|
+
// what the editor opened on and Save is checked against
|
|
78
|
+
const [base, setBase] = useState(source);
|
|
79
|
+
const [draft, setDraft] = useState(initialValue ?? source);
|
|
80
|
+
const [notice, setNotice] = useState<Notice>({ kind: "none" });
|
|
81
|
+
|
|
82
|
+
if (source !== lastSource) {
|
|
83
|
+
setLastSource(source);
|
|
84
|
+
if (draft === base) {
|
|
85
|
+
setDraft(source);
|
|
86
|
+
setBase(source);
|
|
87
|
+
} else if (source !== base) {
|
|
88
|
+
setNotice({ kind: "conflict", source });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dirty = draft !== base;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<View
|
|
96
|
+
className={className === undefined ? "gap-xs" : `gap-xs ${className}`}
|
|
97
|
+
>
|
|
98
|
+
{path === undefined ? null : <Code className="text-sm">{path}</Code>}
|
|
99
|
+
|
|
100
|
+
{language === "markdown" ? (
|
|
101
|
+
<MarkdownEditor
|
|
102
|
+
aria-label={ariaLabel}
|
|
103
|
+
value={draft}
|
|
104
|
+
onChange={(next) => {
|
|
105
|
+
setDraft(next);
|
|
106
|
+
setNotice((current) =>
|
|
107
|
+
current.kind === "conflict" ? current : { kind: "none" },
|
|
108
|
+
);
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
) : (
|
|
112
|
+
<CodeEditor
|
|
113
|
+
aria-label={ariaLabel}
|
|
114
|
+
language={language}
|
|
115
|
+
value={draft}
|
|
116
|
+
cursor={cursor}
|
|
117
|
+
onChange={(next) => {
|
|
118
|
+
setDraft(next);
|
|
119
|
+
setNotice((current) =>
|
|
120
|
+
current.kind === "conflict" ? current : { kind: "none" },
|
|
121
|
+
);
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{notice.kind === "invalid" ? (
|
|
127
|
+
<ErrorMessage>{notice.error}</ErrorMessage>
|
|
128
|
+
) : null}
|
|
129
|
+
|
|
130
|
+
{notice.kind === "saved" && notice.warnings.length > 0 ? (
|
|
131
|
+
<WarningMessage>
|
|
132
|
+
<View className="gap-xxs">
|
|
133
|
+
<Text className="font-body-bold text-sm">
|
|
134
|
+
Saved, with{" "}
|
|
135
|
+
{notice.warnings.length === 1 ? "a warning" : "warnings"}:
|
|
136
|
+
</Text>
|
|
137
|
+
{notice.warnings.map((warning) => (
|
|
138
|
+
<Text key={warning} className="text-sm">
|
|
139
|
+
{warning}
|
|
140
|
+
</Text>
|
|
141
|
+
))}
|
|
142
|
+
</View>
|
|
143
|
+
</WarningMessage>
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{notice.kind === "conflict" ? (
|
|
147
|
+
<WarningMessage>
|
|
148
|
+
<View className="gap-xs">
|
|
149
|
+
<Text className="font-body-bold text-sm">
|
|
150
|
+
The file changed on disk since it was opened.
|
|
151
|
+
</Text>
|
|
152
|
+
<Text className="text-sm">
|
|
153
|
+
Reload to drop your edits and open what is on disk, or keep
|
|
154
|
+
editing: the next save replaces what is on disk now.
|
|
155
|
+
</Text>
|
|
156
|
+
</View>
|
|
157
|
+
</WarningMessage>
|
|
158
|
+
) : null}
|
|
159
|
+
|
|
160
|
+
<View className="flex-row flex-wrap items-center justify-end gap-xs">
|
|
161
|
+
{notice.kind === "saved" && !dirty ? (
|
|
162
|
+
<Text className="mr-auto text-sm text-muted">Saved.</Text>
|
|
163
|
+
) : null}
|
|
164
|
+
{notice.kind === "conflict" ? (
|
|
165
|
+
<Button
|
|
166
|
+
text="Reload"
|
|
167
|
+
accent="neutral"
|
|
168
|
+
variant="soft"
|
|
169
|
+
onPress={() => {
|
|
170
|
+
setBase(notice.source);
|
|
171
|
+
setDraft(notice.source);
|
|
172
|
+
setNotice({ kind: "none" });
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
{notice.kind === "conflict" ? (
|
|
177
|
+
<Button
|
|
178
|
+
text="Keep editing"
|
|
179
|
+
accent="neutral"
|
|
180
|
+
variant="soft"
|
|
181
|
+
onPress={() => {
|
|
182
|
+
setBase(notice.source);
|
|
183
|
+
setNotice({ kind: "none" });
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
187
|
+
{dirty && notice.kind !== "conflict" ? (
|
|
188
|
+
<Button
|
|
189
|
+
text="Revert"
|
|
190
|
+
accent="neutral"
|
|
191
|
+
variant="soft"
|
|
192
|
+
onPress={() => {
|
|
193
|
+
setDraft(base);
|
|
194
|
+
setNotice({ kind: "none" });
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
) : null}
|
|
198
|
+
<ActionButton
|
|
199
|
+
text="Save"
|
|
200
|
+
accent="brand"
|
|
201
|
+
disabled={!dirty || notice.kind === "conflict"}
|
|
202
|
+
errorToMessage={errorToMessage}
|
|
203
|
+
onPress={async () => {
|
|
204
|
+
const submitted = draft;
|
|
205
|
+
const result = await onSave(submitted, base);
|
|
206
|
+
if (result.status === "saved") {
|
|
207
|
+
setBase(result.source);
|
|
208
|
+
setLastSource(result.source);
|
|
209
|
+
// typed while saving: kept, and unsaved against what was saved
|
|
210
|
+
setDraft((current) =>
|
|
211
|
+
current === submitted ? result.source : current,
|
|
212
|
+
);
|
|
213
|
+
setNotice({ kind: "saved", warnings: result.warnings });
|
|
214
|
+
} else if (result.status === "invalid") {
|
|
215
|
+
setNotice({ kind: "invalid", error: result.error });
|
|
216
|
+
} else {
|
|
217
|
+
setNotice({ kind: "conflict", source: result.source });
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
</View>
|
|
222
|
+
</View>
|
|
223
|
+
);
|
|
224
|
+
}
|