@tanishqkancharla/diffmap 0.2.0 → 0.4.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/README.md +107 -4
- package/index.html +6 -0
- package/package.json +3 -2
- package/src/changes.ts +21 -0
- package/src/codeNotes.ts +123 -0
- package/src/errors.ts +5 -0
- package/src/gist/GistApp.tsx +23 -20
- package/src/gist/fetchGist.ts +42 -18
- package/src/git.test.ts +13 -1
- package/src/git.ts +15 -2
- package/src/host/HostHome.tsx +22 -6
- package/src/host/files.ts +5 -1
- package/src/host/home.md +148 -102
- package/src/host/home.test.ts +6 -7
- package/src/parseFence.ts +57 -17
- package/src/parseViewer.test.ts +269 -2
- package/src/parseViewer.ts +86 -20
- package/src/services.ts +289 -0
- package/src/share.ts +50 -7
- package/src/viewer/CallStackDiff.tsx +90 -24
- package/src/viewer/ChangesCard.tsx +133 -43
- package/src/viewer/CodeBlock.tsx +131 -0
- package/src/viewer/Fence.tsx +22 -2
- package/src/viewer/ServiceDiagramBlock.tsx +323 -0
- package/src/viewer/ServiceStage.tsx +988 -0
- package/src/viewer/SourceDiffPanel.tsx +13 -9
- package/src/viewer/TableOfContents.tsx +3 -0
- package/src/viewer/ViewerApp.tsx +18 -3
- package/src/viewer/notes.tsx +126 -0
- package/src/viewer/serviceLayout.ts +189 -0
- package/vite.config.ts +1 -0
- package/vite.gist.config.ts +1 -0
package/README.md
CHANGED
|
@@ -75,16 +75,48 @@ import {
|
|
|
75
75
|
| Fence info string | Viewer |
|
|
76
76
|
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
77
77
|
| `mermaid` | Beautiful Mermaid |
|
|
78
|
+
| `services` | Animated service diagram you can play or step through |
|
|
78
79
|
| `callstack` or `diff` containing `└──` / `├──` | Interactive stack rows, no file header |
|
|
79
80
|
| `diff` or `diff:path` with a file path | Pierre patch with Pierre’s file header |
|
|
80
81
|
| `diff` with no path | Pierre patch, no file header |
|
|
81
82
|
| `changes` with a commit, range, or empty body | Files changed card with a Review button; empty means uncommitted changes |
|
|
82
83
|
| `start:end:path` | Pierre file excerpt with Pierre’s file header |
|
|
83
84
|
| `html` | Trusted HTML from this file. diffmap does not sanitize it. Only use it for local files you wrote. |
|
|
84
|
-
| other langs |
|
|
85
|
+
| other langs | Highlighted code with optional [line highlights and notes](#highlight-lines-and-add-notes) |
|
|
85
86
|
|
|
86
87
|
Fences keep whitespace. Use them for mermaid, call stacks, and diffs.
|
|
87
88
|
|
|
89
|
+
### Highlight lines and add notes
|
|
90
|
+
|
|
91
|
+
In a code fence, `{2,4-6}` after the language highlights those lines. Comment
|
|
92
|
+
lines at the bottom of the fence add notes that show on hover or keyboard focus:
|
|
93
|
+
|
|
94
|
+
````md
|
|
95
|
+
```ts {2-3}
|
|
96
|
+
function requestHandler(record: RecordInput) {
|
|
97
|
+
const error = validateInput(record);
|
|
98
|
+
if (error) return error;
|
|
99
|
+
return existingService(record);
|
|
100
|
+
}
|
|
101
|
+
// [!note validateInput] Returns an error when `name` is blank, otherwise undefined.
|
|
102
|
+
// [!note L3] Early return, so storage never sees an invalid record.
|
|
103
|
+
```
|
|
104
|
+
````
|
|
105
|
+
|
|
106
|
+
- `[!note L3]` or `[!note L3-L5]` notes a line range. Any other target is a
|
|
107
|
+
symbol, and the note covers every whole-word occurrence in the fence.
|
|
108
|
+
- Start note lines with `//`, `#`, or `--`. The viewer removes them, so line
|
|
109
|
+
numbers count only the code above. Backticks in a note render as code.
|
|
110
|
+
- A symbol that does not appear, overlapping line notes, or a line number
|
|
111
|
+
outside the fence is a parse error. A note line above other code stays code.
|
|
112
|
+
|
|
113
|
+
Call stacks take the same highlights and notes, counting rows from the first.
|
|
114
|
+
Write their notes with `#`. A symbol note matches the row text you see, not
|
|
115
|
+
hidden references. Inside a linked row, symbol notes show on hover only; the
|
|
116
|
+
row itself keeps keyboard focus.
|
|
117
|
+
|
|
118
|
+
Run `npx @tanishqkancharla/diffmap fixtures/code-notes.md` for an example.
|
|
119
|
+
|
|
88
120
|
## MDC
|
|
89
121
|
|
|
90
122
|
md4x Comark components map to the same views:
|
|
@@ -154,9 +186,17 @@ A `changes` fence shows a commit's files as a card. Each row shows the file name
|
|
|
154
186
|
and +N −M. **Review** opens the Diff panel on all files; a row focuses that file.
|
|
155
187
|
An empty body (or `working`) means uncommitted changes.
|
|
156
188
|
|
|
189
|
+
Lines after the rev list the files to review, in order. The card shows those
|
|
190
|
+
first and folds the rest under a dimmed **N other files** row that expands and
|
|
191
|
+
collapses. Review opens the Diff panel in the same order. `serve` and `share`
|
|
192
|
+
fail if a listed file is not changed in that commit. With no list, the card
|
|
193
|
+
shows every file.
|
|
194
|
+
|
|
157
195
|
````md
|
|
158
196
|
```changes
|
|
159
197
|
a1b2c3d
|
|
198
|
+
src/request.ts
|
|
199
|
+
src/validate.ts
|
|
160
200
|
```
|
|
161
201
|
````
|
|
162
202
|
|
|
@@ -170,9 +210,16 @@ Malformed references produce a parse error.
|
|
|
170
210
|
|
|
171
211
|
On a GitHub-hosted spec, commit diffs come from the GitHub API. It returns patch
|
|
172
212
|
hunks only, so only lines near changes are reachable, and the Diff button opens
|
|
173
|
-
the pull request's full diff.
|
|
174
|
-
|
|
175
|
-
|
|
213
|
+
the pull request's full diff.
|
|
214
|
+
|
|
215
|
+
A shared gist is a snapshot taken when you run `diffmap share`. It saves every
|
|
216
|
+
referenced commit and every `changes` card, including uncommitted ones, to
|
|
217
|
+
`diffmap-changes.json`. It saves the current contents of every file that a file
|
|
218
|
+
excerpt or a reference without a commit reads to `diffmap-files.json`. In the
|
|
219
|
+
gist, those references open the saved file; symbols resolve to their first
|
|
220
|
+
matching line, and Cmd-click definitions only work in a local viewer. Commit
|
|
221
|
+
your work and point references at the commit before sharing, so the gist shows
|
|
222
|
+
the diff instead of a bare file.
|
|
176
223
|
|
|
177
224
|
Cmd-click (or Ctrl-click) a TypeScript or JavaScript symbol in the source panel
|
|
178
225
|
to open its definition. Use Back to retrace navigation and return to the reference.
|
|
@@ -181,6 +228,62 @@ Resolution uses the workspace's TypeScript configuration and current files.
|
|
|
181
228
|
Run `npx @tanishqkancharla/diffmap fixtures/annotations.md` for an example
|
|
182
229
|
with commit references, multiple files, and unchanged context.
|
|
183
230
|
|
|
231
|
+
## Animated service diagrams
|
|
232
|
+
|
|
233
|
+
A `services` fence shows services, their state, and the events they send each
|
|
234
|
+
other, then replays those events step by step. Readers press **Play**, step with
|
|
235
|
+
the arrow buttons or ← and →, or click the progress bar to jump to a step.
|
|
236
|
+
|
|
237
|
+
````md
|
|
238
|
+
```services
|
|
239
|
+
service Browser "login page"
|
|
240
|
+
state cookie = none
|
|
241
|
+
service API "HTTP server" [[src/api.ts]]
|
|
242
|
+
> Receives every request and routes it to a service.
|
|
243
|
+
service Auth
|
|
244
|
+
state attempts = 0
|
|
245
|
+
> Failures since the last success. Five locks the account.
|
|
246
|
+
|
|
247
|
+
step "The browser submits the form"
|
|
248
|
+
Browser -> API: POST /login
|
|
249
|
+
step "The API asks Auth to verify"
|
|
250
|
+
API -> Auth: verify(alice) [[src/api.ts#login]]
|
|
251
|
+
> Sends the username and the password from the form.
|
|
252
|
+
Auth.attempts = 1
|
|
253
|
+
> Counted before the check, so a crash still counts as a failure.
|
|
254
|
+
step "The API sets the session cookie"
|
|
255
|
+
API -> Browser: 200 Set-Cookie
|
|
256
|
+
Browser.cookie = sid=s_81f
|
|
257
|
+
```
|
|
258
|
+
````
|
|
259
|
+
|
|
260
|
+
- `service <Name> "label"` declares a service. Indent a `service` line under
|
|
261
|
+
another to nest it. Names are unique within the diagram.
|
|
262
|
+
- `state <key> = <value>` declares a value and its initial state. Indent it under
|
|
263
|
+
its service.
|
|
264
|
+
- `step "caption"` starts a step at column 0. The caption is required: a
|
|
265
|
+
sentence that says what happens, shown under the controls. Indented lines
|
|
266
|
+
under it are `From -> To: label` events and `Service.key = value` updates.
|
|
267
|
+
Every event in a step travels at once, and the updates land when the events
|
|
268
|
+
arrive.
|
|
269
|
+
- A `>` line adds text to the line above it. Indent it further than that line;
|
|
270
|
+
consecutive `>` lines join.
|
|
271
|
+
- Under a service or state, it is a description: the name gets a dotted
|
|
272
|
+
underline and the text shows in a tooltip on hover or focus.
|
|
273
|
+
- Under a step, it explains the step and shows under the caption.
|
|
274
|
+
- Under an event or update, it is a note for that step. Notes appear in a
|
|
275
|
+
column beside the diagram once the step's events land, each joined by a
|
|
276
|
+
line to its event label or state row.
|
|
277
|
+
- `%%` starts a comment line.
|
|
278
|
+
- Append `[[reference]]` to a `service` line or an event to link it to source.
|
|
279
|
+
|
|
280
|
+
Services must be declared before the first step, and updates may only change
|
|
281
|
+
declared state. [ELK](https://eclipse.dev/elk/) lays the services out left to
|
|
282
|
+
right, or top to bottom when that fits the column better, and routes one
|
|
283
|
+
connection between each pair of services that talk. A request and its reply
|
|
284
|
+
share that connection. Indentation is significant, so `services` has no MDC form.
|
|
285
|
+
See [the runnable examples](fixtures/services.md).
|
|
286
|
+
|
|
184
287
|
## Link Mermaid nodes and edges
|
|
185
288
|
|
|
186
289
|
Inside a Mermaid fence, add `%% ref node:<id> [[reference]]` to link a node or
|
package/index.html
CHANGED
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
})();
|
|
49
49
|
</script>
|
|
50
50
|
<title>diffmap</title>
|
|
51
|
+
<!-- A + over a −, like a diff. -->
|
|
52
|
+
<link
|
|
53
|
+
rel="icon"
|
|
54
|
+
type="image/svg+xml"
|
|
55
|
+
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2318181b'/%3E%3Crect x='14.25' y='5.5' width='3.5' height='11' rx='1.75' fill='%233fb950'/%3E%3Crect x='10.5' y='9.25' width='11' height='3.5' rx='1.75' fill='%233fb950'/%3E%3Crect x='10.5' y='21.25' width='11' height='3.5' rx='1.75' fill='%23f85149'/%3E%3C/svg%3E"
|
|
56
|
+
/>
|
|
51
57
|
</head>
|
|
52
58
|
<body>
|
|
53
59
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanishqkancharla/diffmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Serve a spec or code walkthrough as a local page",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tanishq Kancharla",
|
|
@@ -44,9 +44,10 @@
|
|
|
44
44
|
"@pierre/diffs": "1.6.1",
|
|
45
45
|
"@vitejs/plugin-react": "^6.1.1",
|
|
46
46
|
"beautiful-mermaid": "^1.1.3",
|
|
47
|
+
"elkjs": "^0.11.1",
|
|
47
48
|
"errore": "^0.14.1",
|
|
48
49
|
"incur": "^0.4.20",
|
|
49
|
-
"maui": "npm:@tanishqkancharla/maui@0.0.
|
|
50
|
+
"maui": "npm:@tanishqkancharla/maui@0.0.62",
|
|
50
51
|
"md4x": "^0.0.29",
|
|
51
52
|
"motion": "^13.4.0",
|
|
52
53
|
"purse-styles": "^0.2.3",
|
package/src/changes.ts
CHANGED
|
@@ -156,9 +156,30 @@ export type Changes = {
|
|
|
156
156
|
files: ChangedFile[];
|
|
157
157
|
};
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Splits files into the ones to review, in `review` order, and the rest in
|
|
161
|
+
* their original order. An empty `review` means every file is to be reviewed.
|
|
162
|
+
*/
|
|
163
|
+
export function orderForReview(files: ChangedFile[], review: string[]) {
|
|
164
|
+
if (review.length === 0) return { reviewed: files, other: [] };
|
|
165
|
+
const reviewed = review.flatMap(
|
|
166
|
+
(path) => files.find((file) => file.path === path) ?? [],
|
|
167
|
+
);
|
|
168
|
+
return {
|
|
169
|
+
reviewed,
|
|
170
|
+
other: files.filter((file) => !reviewed.includes(file)),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
/** Gist file holding the changes a shared spec references, keyed by rev. */
|
|
160
175
|
export const GIST_CHANGES_FILE = "diffmap-changes.json";
|
|
161
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Gist file holding the current contents of files a shared spec reads live,
|
|
179
|
+
* keyed by path: file excerpts and references without a commit.
|
|
180
|
+
*/
|
|
181
|
+
export const GIST_FILES_FILE = "diffmap-files.json";
|
|
182
|
+
|
|
162
183
|
export type ChangesResponse = { changes?: Changes; error?: string };
|
|
163
184
|
|
|
164
185
|
/** jsdiff's default lines of context around each change. */
|
package/src/codeNotes.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type CodeNote = {
|
|
2
|
+
target:
|
|
3
|
+
| { kind: "lines"; start: number; end: number }
|
|
4
|
+
| { kind: "symbol"; symbol: string };
|
|
5
|
+
text: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** A symbol note's match on one line: `[start, end)` columns. */
|
|
9
|
+
export type SymbolMatch = { start: number; end: number; note: CodeNote };
|
|
10
|
+
|
|
11
|
+
const notePrefix = /^\s*(?:\/\/|#|--)\s*\[!note\b/;
|
|
12
|
+
const notePattern = /^\s*(?:\/\/|#|--)\s*\[!note\s+([^\]]+?)\s*\]\s*(.*?)\s*$/;
|
|
13
|
+
const linesPattern = /^L(\d+)(?:-L(\d+))?$/;
|
|
14
|
+
const identifierChar = /[\w$]/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Splits trailing `// [!note target] text` lines off a code fence. Blank
|
|
18
|
+
* lines between notes are allowed; note-like lines above other code stay
|
|
19
|
+
* code, so fences that document this syntax render unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export function parseCodeNotes(source: string) {
|
|
22
|
+
const lines = source.split("\n");
|
|
23
|
+
let end = lines.length;
|
|
24
|
+
while (
|
|
25
|
+
end > 0 &&
|
|
26
|
+
(lines[end - 1]!.trim() === "" || notePrefix.test(lines[end - 1]!))
|
|
27
|
+
)
|
|
28
|
+
end--;
|
|
29
|
+
const notes = lines.slice(end).flatMap((line) => {
|
|
30
|
+
if (line.trim() === "") return [];
|
|
31
|
+
const match = notePattern.exec(line);
|
|
32
|
+
if (match === null || match[2] === "")
|
|
33
|
+
return [{ target: { kind: "symbol", symbol: "" }, text: line } as const];
|
|
34
|
+
const lineRange = linesPattern.exec(match[1]!);
|
|
35
|
+
const target: CodeNote["target"] =
|
|
36
|
+
lineRange === null
|
|
37
|
+
? { kind: "symbol", symbol: match[1]! }
|
|
38
|
+
: {
|
|
39
|
+
kind: "lines",
|
|
40
|
+
start: Number(lineRange[1]),
|
|
41
|
+
end: Number(lineRange[2] ?? lineRange[1]),
|
|
42
|
+
};
|
|
43
|
+
return [{ target, text: match[2]! }];
|
|
44
|
+
});
|
|
45
|
+
return { code: lines.slice(0, end).join("\n"), notes };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Why a fence's highlights or notes are invalid, or undefined. `lines` is the rendered text of each line or row. */
|
|
49
|
+
export function notesProblem(fence: {
|
|
50
|
+
lines: string[];
|
|
51
|
+
highlights: number[];
|
|
52
|
+
notes: CodeNote[];
|
|
53
|
+
}) {
|
|
54
|
+
const lines = fence.lines;
|
|
55
|
+
const highlight = fence.highlights.find((n) => n < 1 || n > lines.length);
|
|
56
|
+
if (highlight !== undefined)
|
|
57
|
+
return `Highlighted line ${highlight} is outside the ${lines.length}-line code fence`;
|
|
58
|
+
const covered = new Set<number>();
|
|
59
|
+
for (const note of fence.notes) {
|
|
60
|
+
const target = note.target;
|
|
61
|
+
if (target.kind === "symbol") {
|
|
62
|
+
if (target.symbol === "")
|
|
63
|
+
return `Invalid note "${note.text.trim()}". Use // [!note symbol] text or // [!note L3-L5] text`;
|
|
64
|
+
if (!lines.some((line) => symbolMatches(line, target.symbol).length > 0))
|
|
65
|
+
return `Note target "${target.symbol}" does not appear in its code fence`;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
target.start < 1 ||
|
|
70
|
+
target.end < target.start ||
|
|
71
|
+
target.end > lines.length
|
|
72
|
+
)
|
|
73
|
+
return `Note lines L${target.start}-L${target.end} are outside the ${lines.length}-line code fence`;
|
|
74
|
+
for (let n = target.start; n <= target.end; n++) {
|
|
75
|
+
if (covered.has(n)) return `Line notes overlap at line ${n}`;
|
|
76
|
+
covered.add(n);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Non-overlapping symbol note matches on one line, left to right. */
|
|
83
|
+
export function lineSymbolMatches(line: string, notes: CodeNote[]) {
|
|
84
|
+
const matches = notes
|
|
85
|
+
.flatMap((note) =>
|
|
86
|
+
note.target.kind === "symbol"
|
|
87
|
+
? symbolMatches(line, note.target.symbol).map((start) => ({
|
|
88
|
+
start,
|
|
89
|
+
end:
|
|
90
|
+
start +
|
|
91
|
+
(note.target.kind === "symbol" ? note.target.symbol.length : 0),
|
|
92
|
+
note,
|
|
93
|
+
}))
|
|
94
|
+
: [],
|
|
95
|
+
)
|
|
96
|
+
.toSorted((a, b) => a.start - b.start || b.end - a.end);
|
|
97
|
+
const kept: SymbolMatch[] = [];
|
|
98
|
+
for (const match of matches) {
|
|
99
|
+
const last = kept.at(-1);
|
|
100
|
+
if (last === undefined || match.start >= last.end) kept.push(match);
|
|
101
|
+
}
|
|
102
|
+
return kept;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Start columns of `symbol` in `line`, as a whole word where it starts or ends with an identifier character. */
|
|
106
|
+
function symbolMatches(line: string, symbol: string) {
|
|
107
|
+
const starts: number[] = [];
|
|
108
|
+
for (
|
|
109
|
+
let at = line.indexOf(symbol);
|
|
110
|
+
at !== -1;
|
|
111
|
+
at = line.indexOf(symbol, at + 1)
|
|
112
|
+
) {
|
|
113
|
+
const before = line[at - 1];
|
|
114
|
+
const after = line[at + symbol.length];
|
|
115
|
+
const wordStart = identifierChar.test(symbol[0]!);
|
|
116
|
+
const wordEnd = identifierChar.test(symbol.at(-1)!);
|
|
117
|
+
if (wordStart && before !== undefined && identifierChar.test(before))
|
|
118
|
+
continue;
|
|
119
|
+
if (wordEnd && after !== undefined && identifierChar.test(after)) continue;
|
|
120
|
+
starts.push(at);
|
|
121
|
+
}
|
|
122
|
+
return starts;
|
|
123
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -20,6 +20,11 @@ export class DiffmapMermaidError extends errore.createTaggedError({
|
|
|
20
20
|
message: "diffmap could not parse mermaid in $path ($where): $reason",
|
|
21
21
|
}) {}
|
|
22
22
|
|
|
23
|
+
export class DiffmapServicesError extends errore.createTaggedError({
|
|
24
|
+
name: "DiffmapServicesError",
|
|
25
|
+
message: "diffmap could not parse services in $path ($where): $reason",
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
23
28
|
export class DiffmapParseError extends errore.createTaggedError({
|
|
24
29
|
name: "DiffmapParseError",
|
|
25
30
|
message: "diffmap could not parse markdown",
|
package/src/gist/GistApp.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { ViewerApp } from "../viewer/ViewerApp.tsx";
|
|
|
13
13
|
import { loadGistMarkdown, type GistDocument } from "./fetchGist.ts";
|
|
14
14
|
import { GistPinContext } from "./pin.ts";
|
|
15
15
|
import { HostHome } from "../host/HostHome.tsx";
|
|
16
|
+
import { HostFilesContext } from "../host/files.ts";
|
|
16
17
|
import { GistLoading, GistStatus } from "./GistStatus.tsx";
|
|
17
18
|
import {
|
|
18
19
|
gistPath,
|
|
@@ -166,26 +167,28 @@ export function GistApp() {
|
|
|
166
167
|
htmlUrl: view.gist.htmlUrl,
|
|
167
168
|
}}
|
|
168
169
|
>
|
|
169
|
-
<
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
170
|
+
<HostFilesContext.Provider value={view.gist.sourceFiles}>
|
|
171
|
+
<ChangesLoaderContext.Provider value={view.loadChanges}>
|
|
172
|
+
<ViewerApp
|
|
173
|
+
key={`${view.gist.id}:${view.file}`}
|
|
174
|
+
document={view.document}
|
|
175
|
+
mode="gist"
|
|
176
|
+
headerActions={
|
|
177
|
+
<GistHeader
|
|
178
|
+
gistId={view.gist.id}
|
|
179
|
+
htmlUrl={view.gist.htmlUrl}
|
|
180
|
+
files={view.gist.files}
|
|
181
|
+
current={view.file}
|
|
182
|
+
onSelect={(file) => {
|
|
183
|
+
const next = gistPath(view.gist.id, file);
|
|
184
|
+
window.history.pushState({}, "", next);
|
|
185
|
+
setRoute(parseGistPath(next));
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
}
|
|
189
|
+
/>
|
|
190
|
+
</ChangesLoaderContext.Provider>
|
|
191
|
+
</HostFilesContext.Provider>
|
|
189
192
|
</GistPinContext.Provider>
|
|
190
193
|
);
|
|
191
194
|
}
|
package/src/gist/fetchGist.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
GIST_CHANGES_FILE,
|
|
3
|
+
GIST_FILES_FILE,
|
|
4
|
+
type Changes,
|
|
5
|
+
type Rev,
|
|
6
|
+
} from "../changes.ts";
|
|
7
|
+
import type { HostFiles } from "../host/files.ts";
|
|
2
8
|
import { githubGistUrl, markdownFileNames, pickMarkdownFile } from "./route.ts";
|
|
3
9
|
|
|
4
10
|
export type GistMarkdownFile = {
|
|
@@ -11,8 +17,10 @@ export type GistDocument = {
|
|
|
11
17
|
htmlUrl: string;
|
|
12
18
|
files: GistMarkdownFile[];
|
|
13
19
|
chosen: GistMarkdownFile;
|
|
14
|
-
/** Snapshot `diffmap share` saved for each commit the spec references. */
|
|
20
|
+
/** Snapshot `diffmap share` saved for each commit and changes card the spec references. */
|
|
15
21
|
changes: Record<Rev, Changes>;
|
|
22
|
+
/** Files excerpts and references without a commit read, as they were when shared. */
|
|
23
|
+
sourceFiles: HostFiles;
|
|
16
24
|
};
|
|
17
25
|
|
|
18
26
|
export type GistLoadResult =
|
|
@@ -98,23 +106,21 @@ export async function loadGistMarkdown(
|
|
|
98
106
|
return { status: "missing-file", htmlUrl, files };
|
|
99
107
|
}
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return { status: "error", message: snapshot.message };
|
|
109
|
+
// SAFETY: diffmap share writes this file from readDocumentChanges.
|
|
110
|
+
const changes = (await readSnapshot(apiFiles, GIST_CHANGES_FILE, signal)) as
|
|
111
|
+
| Record<Rev, Changes>
|
|
112
|
+
| Error;
|
|
113
|
+
if (changes instanceof Error) {
|
|
114
|
+
return { status: "error", message: changes.message };
|
|
108
115
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
};
|
|
116
|
+
// SAFETY: diffmap share writes this file as path to contents.
|
|
117
|
+
const sourceFiles = (await readSnapshot(
|
|
118
|
+
apiFiles,
|
|
119
|
+
GIST_FILES_FILE,
|
|
120
|
+
signal,
|
|
121
|
+
)) as HostFiles | Error;
|
|
122
|
+
if (sourceFiles instanceof Error) {
|
|
123
|
+
return { status: "error", message: sourceFiles.message };
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
return {
|
|
@@ -125,10 +131,28 @@ export async function loadGistMarkdown(
|
|
|
125
131
|
files,
|
|
126
132
|
chosen,
|
|
127
133
|
changes,
|
|
134
|
+
sourceFiles,
|
|
128
135
|
},
|
|
129
136
|
};
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
/** A JSON file `diffmap share` wrote, or `{}` when the gist predates it. */
|
|
140
|
+
async function readSnapshot(
|
|
141
|
+
apiFiles: Record<string, GistApiFile>,
|
|
142
|
+
name: string,
|
|
143
|
+
signal?: AbortSignal,
|
|
144
|
+
): Promise<unknown> {
|
|
145
|
+
const file = apiFiles[name];
|
|
146
|
+
if (file === undefined) return {};
|
|
147
|
+
const text = await readGistFile(file, signal);
|
|
148
|
+
if (text instanceof Error) return text;
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(text) as unknown;
|
|
151
|
+
} catch {
|
|
152
|
+
return new Error(`${name} is not valid JSON.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
async function readGistFile(file: GistApiFile, signal?: AbortSignal) {
|
|
133
157
|
const name = file.filename ?? "file";
|
|
134
158
|
if (file.truncated !== true && typeof file.content === "string") {
|
package/src/git.test.ts
CHANGED
|
@@ -249,7 +249,7 @@ test("readDocumentChanges rejects unchanged files and ranges past the end", asyn
|
|
|
249
249
|
const sha = (await git("rev-parse", "HEAD")).slice(0, 7);
|
|
250
250
|
const check = (reference: { path: string; start?: number; end?: number }) =>
|
|
251
251
|
readDocumentChanges(root, {
|
|
252
|
-
|
|
252
|
+
changeCards: [],
|
|
253
253
|
references: [{ kind: "file", rev: sha, ...reference }],
|
|
254
254
|
});
|
|
255
255
|
|
|
@@ -269,6 +269,18 @@ test("readDocumentChanges rejects unchanged files and ranges past the end", asyn
|
|
|
269
269
|
unchanged instanceof Error ? unchanged.message : "",
|
|
270
270
|
/gone\.ts is not changed/,
|
|
271
271
|
);
|
|
272
|
+
|
|
273
|
+
const review = (paths: string[]) =>
|
|
274
|
+
readDocumentChanges(root, {
|
|
275
|
+
changeCards: [{ rev: sha, review: paths }],
|
|
276
|
+
references: [],
|
|
277
|
+
});
|
|
278
|
+
assert.equal((await review(["keep.ts"])) instanceof Error, false);
|
|
279
|
+
const missing = await review(["keep.ts", "gone.ts"]);
|
|
280
|
+
assert.match(
|
|
281
|
+
missing instanceof Error ? missing.message : "",
|
|
282
|
+
/lists gone\.ts to review, but it is not changed/,
|
|
283
|
+
);
|
|
272
284
|
});
|
|
273
285
|
});
|
|
274
286
|
|
package/src/git.ts
CHANGED
|
@@ -462,10 +462,13 @@ function totals(entries: NumstatEntry[]) {
|
|
|
462
462
|
*/
|
|
463
463
|
export async function readDocumentChanges(
|
|
464
464
|
root: string,
|
|
465
|
-
document: Pick<ViewerDocument, "references" | "
|
|
465
|
+
document: Pick<ViewerDocument, "references" | "changeCards">,
|
|
466
466
|
): Promise<Map<Rev, Changes> | DiffmapGitError> {
|
|
467
467
|
// Uncommitted revs are read live; only commits are checked and snapshotted.
|
|
468
|
-
const
|
|
468
|
+
const commitCards = document.changeCards.filter((card) =>
|
|
469
|
+
isCommitRev(card.rev),
|
|
470
|
+
);
|
|
471
|
+
const revs = new Set<Rev>(commitCards.map((card) => card.rev));
|
|
469
472
|
for (const reference of document.references) {
|
|
470
473
|
if (reference.kind === "changes" || reference.rev !== undefined)
|
|
471
474
|
revs.add(reference.rev!);
|
|
@@ -476,6 +479,16 @@ export async function readDocumentChanges(
|
|
|
476
479
|
if (changes instanceof Error) return changes;
|
|
477
480
|
loaded.set(rev, changes);
|
|
478
481
|
}
|
|
482
|
+
for (const card of commitCards) {
|
|
483
|
+
const files = loaded.get(card.rev)!.files;
|
|
484
|
+
const missing = card.review.find(
|
|
485
|
+
(reviewPath) => !files.some((file) => file.path === reviewPath),
|
|
486
|
+
);
|
|
487
|
+
if (missing !== undefined)
|
|
488
|
+
return new DiffmapGitError({
|
|
489
|
+
reason: `changes ${card.rev} lists ${missing} to review, but it is not changed in ${card.rev}.`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
479
492
|
for (const reference of document.references) {
|
|
480
493
|
if (reference.kind !== "file" || reference.rev === undefined) continue;
|
|
481
494
|
const file = loaded
|
package/src/host/HostHome.tsx
CHANGED
|
@@ -12,10 +12,18 @@ import {
|
|
|
12
12
|
} from "../viewer/changesSource.ts";
|
|
13
13
|
import { HostFilesContext } from "./files.ts";
|
|
14
14
|
import homeSource from "./home.md?raw";
|
|
15
|
-
import cliSource from "../cli.ts?raw";
|
|
16
15
|
import shareSource from "../share.ts?raw";
|
|
17
|
-
import
|
|
18
|
-
import
|
|
16
|
+
import serveSource from "../serve.ts?raw";
|
|
17
|
+
import gitSource from "../git.ts?raw";
|
|
18
|
+
import fetchRepoSource from "../github/fetchRepo.ts?raw";
|
|
19
|
+
import fenceSource from "../viewer/Fence.tsx?raw";
|
|
20
|
+
import callStackSource from "../viewer/CallStackDiff.tsx?raw";
|
|
21
|
+
import sourceNavigationSource from "../viewer/sourceNavigation.ts?raw";
|
|
22
|
+
import sourceDiffPanelSource from "../viewer/SourceDiffPanel.tsx?raw";
|
|
23
|
+
import viewerAppSource from "../viewer/ViewerApp.tsx?raw";
|
|
24
|
+
import parseFenceSource from "../parseFence.ts?raw";
|
|
25
|
+
import annotationsSource from "../annotations.ts?raw";
|
|
26
|
+
import hostFilesSource from "./files.ts?raw";
|
|
19
27
|
|
|
20
28
|
let parserReady: Promise<void> | undefined;
|
|
21
29
|
|
|
@@ -25,10 +33,18 @@ function readyHostParser() {
|
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
const hostFiles = {
|
|
28
|
-
"src/cli.ts": cliSource,
|
|
29
36
|
"src/share.ts": shareSource,
|
|
30
|
-
"src/
|
|
31
|
-
"src/
|
|
37
|
+
"src/serve.ts": serveSource,
|
|
38
|
+
"src/git.ts": gitSource,
|
|
39
|
+
"src/github/fetchRepo.ts": fetchRepoSource,
|
|
40
|
+
"src/viewer/Fence.tsx": fenceSource,
|
|
41
|
+
"src/viewer/CallStackDiff.tsx": callStackSource,
|
|
42
|
+
"src/viewer/sourceNavigation.ts": sourceNavigationSource,
|
|
43
|
+
"src/viewer/SourceDiffPanel.tsx": sourceDiffPanelSource,
|
|
44
|
+
"src/viewer/ViewerApp.tsx": viewerAppSource,
|
|
45
|
+
"src/parseFence.ts": parseFenceSource,
|
|
46
|
+
"src/annotations.ts": annotationsSource,
|
|
47
|
+
"src/host/files.ts": hostFilesSource,
|
|
32
48
|
};
|
|
33
49
|
|
|
34
50
|
/** The homepage walks through diffmap's own commits, read from GitHub. */
|
package/src/host/files.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createContext, useContext } from "react";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Files a hosted viewer has without a server: the homepage's bundled sources,
|
|
5
|
+
* or the files `diffmap share` saved in a gist. `[[path]]` and file excerpts
|
|
6
|
+
* read these, not GitHub.
|
|
7
|
+
*/
|
|
4
8
|
export type HostFiles = Record<string, string>;
|
|
5
9
|
|
|
6
10
|
export const HostFilesContext = createContext<HostFiles | undefined>(undefined);
|