@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.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +101 -3
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. 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. `![](x)` 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
+ }