@tanishqkancharla/diffmap 0.3.0 → 0.6.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 +171 -1
- package/index.html +6 -0
- package/package.json +3 -2
- package/src/annotations.ts +17 -1
- package/src/changes.ts +15 -0
- package/src/codeNotes.ts +123 -0
- package/src/errors.ts +5 -0
- package/src/extractDocument.ts +10 -2
- package/src/git.test.ts +13 -1
- package/src/git.ts +15 -2
- package/src/host/HostHome.tsx +22 -6
- package/src/host/home.md +154 -109
- package/src/host/home.test.ts +6 -8
- package/src/parseFence.ts +132 -23
- package/src/parseViewer.test.ts +359 -2
- package/src/parseViewer.ts +126 -25
- package/src/serve.test.ts +22 -1
- package/src/services.ts +396 -0
- package/src/share.ts +1 -1
- package/src/viewer/CallStackDiff.tsx +119 -55
- package/src/viewer/ChangesCard.tsx +133 -45
- package/src/viewer/CodeBlock.tsx +204 -0
- package/src/viewer/ComarkView.tsx +34 -0
- package/src/viewer/Fence.tsx +26 -2
- package/src/viewer/HtmlBlock.tsx +2 -1
- package/src/viewer/MermaidBlock.tsx +55 -14
- package/src/viewer/ServiceDiagramBlock.tsx +358 -0
- package/src/viewer/ServiceStage.tsx +1129 -0
- package/src/viewer/SourceDiffPanel.tsx +147 -48
- package/src/viewer/TableOfContents.tsx +47 -18
- package/src/viewer/TocButton.tsx +28 -0
- package/src/viewer/ViewerApp.tsx +28 -3
- package/src/viewer/diagramDiff.ts +69 -0
- package/src/viewer/diagramLinks.ts +54 -6
- package/src/viewer/notes.tsx +126 -0
- package/src/viewer/pierre.ts +20 -2
- package/src/viewer/serviceLayout.ts +200 -0
- package/vite.config.ts +5 -0
- package/vite.gist.config.ts +1 -0
package/src/services.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { parseAnnotation, type SourceAnnotation } from "./annotations.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `services` fence: services that hold state, nest sub-services, and send
|
|
5
|
+
* each other events. Steps replay the events in order. Every event in a step
|
|
6
|
+
* is in flight at once; the step's state updates land when they arrive.
|
|
7
|
+
*/
|
|
8
|
+
export type ServiceDiagram = {
|
|
9
|
+
services: Service[];
|
|
10
|
+
/** From `start "caption"`: what the declared state shows, before step 1. */
|
|
11
|
+
start: { caption: string; description: string | undefined } | undefined;
|
|
12
|
+
steps: ServiceStep[];
|
|
13
|
+
/** From ```services diff: items carry `+` or `-` signs. */
|
|
14
|
+
diff: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In a `services diff`, `-` marks an item that exists only Today and `+` one
|
|
19
|
+
* that exists only in the Proposed version. Items inherit the sign of the
|
|
20
|
+
* service or step they sit in.
|
|
21
|
+
*/
|
|
22
|
+
export type Sign = "+" | "-" | " ";
|
|
23
|
+
|
|
24
|
+
export type Side = "today" | "proposed";
|
|
25
|
+
|
|
26
|
+
export type Service = {
|
|
27
|
+
name: string;
|
|
28
|
+
label: string | undefined;
|
|
29
|
+
description: string | undefined;
|
|
30
|
+
state: ServiceState[];
|
|
31
|
+
children: Service[];
|
|
32
|
+
annotation: SourceAnnotation | undefined;
|
|
33
|
+
sign: Sign;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ServiceState = {
|
|
37
|
+
key: string;
|
|
38
|
+
/** The Proposed initial value. */
|
|
39
|
+
value: string;
|
|
40
|
+
/** Set when a `-` and a `+` line declare the key with different values. */
|
|
41
|
+
todayValue: string | undefined;
|
|
42
|
+
description: string | undefined;
|
|
43
|
+
sign: Sign;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ServiceStep = {
|
|
47
|
+
/** What happens in this step, in a sentence. */
|
|
48
|
+
caption: string;
|
|
49
|
+
description: string | undefined;
|
|
50
|
+
events: ServiceEvent[];
|
|
51
|
+
updates: ServiceUpdate[];
|
|
52
|
+
sign: Sign;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ServiceEvent = {
|
|
56
|
+
from: string;
|
|
57
|
+
to: string;
|
|
58
|
+
label: string;
|
|
59
|
+
/** Shown beside the event's label while its step is current. */
|
|
60
|
+
note: string | undefined;
|
|
61
|
+
annotation: SourceAnnotation | undefined;
|
|
62
|
+
sign: Sign;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ServiceUpdate = {
|
|
66
|
+
service: string;
|
|
67
|
+
key: string;
|
|
68
|
+
value: string;
|
|
69
|
+
/** Shown beside the changed state while its step is current. */
|
|
70
|
+
note: string | undefined;
|
|
71
|
+
sign: Sign;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ServicesParse =
|
|
75
|
+
| { ok: true; diagram: ServiceDiagram }
|
|
76
|
+
| { ok: false; line: number; reason: string };
|
|
77
|
+
|
|
78
|
+
const name = String.raw`[A-Za-z_]\w*`;
|
|
79
|
+
const serviceLine = new RegExp(
|
|
80
|
+
String.raw`^service\s+(${name})(?:\s+"([^"]*)")?\s*$`,
|
|
81
|
+
);
|
|
82
|
+
const stateLine = new RegExp(String.raw`^state\s+(${name})\s*=\s*(.+)$`);
|
|
83
|
+
const stepLine = /^step\b(?:\s+"([^"]*)")?\s*$/;
|
|
84
|
+
const startLine = /^start\b(?:\s+"([^"]*)")?\s*$/;
|
|
85
|
+
const eventLine = new RegExp(
|
|
86
|
+
String.raw`^(${name})\s*->\s*(${name})(?:\s*:\s*(.*))?$`,
|
|
87
|
+
);
|
|
88
|
+
const updateLine = new RegExp(String.raw`^(${name})\.(${name})\s*=\s*(.+)$`);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* ```services
|
|
92
|
+
* service API "HTTP server" [[src/api.ts]]
|
|
93
|
+
* state requests = 0
|
|
94
|
+
* service Auth
|
|
95
|
+
* state attempts = 0
|
|
96
|
+
*
|
|
97
|
+
* step "Check the password"
|
|
98
|
+
* API -> Auth: verify(alice) [[src/api.ts#login]]
|
|
99
|
+
* Auth.attempts = 1
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function parseServiceDiagram(
|
|
103
|
+
source: string,
|
|
104
|
+
diff = false,
|
|
105
|
+
): ServicesParse {
|
|
106
|
+
const services: Service[] = [];
|
|
107
|
+
const byName = new Map<string, Service>();
|
|
108
|
+
const steps: ServiceStep[] = [];
|
|
109
|
+
let start: ServiceDiagram["start"];
|
|
110
|
+
// Open services, innermost last, with the indent of their `service` line.
|
|
111
|
+
const open: { indent: number; service: Service }[] = [];
|
|
112
|
+
// Where a `>` line's text goes: a description on a service, state, or
|
|
113
|
+
// step, or a note on an event or update.
|
|
114
|
+
let described: { indent: number; append: (text: string) => void } | undefined;
|
|
115
|
+
const describe = (indent: number, item: { description?: string }) => {
|
|
116
|
+
described = {
|
|
117
|
+
indent,
|
|
118
|
+
append: (more) => {
|
|
119
|
+
item.description =
|
|
120
|
+
item.description === undefined ? more : `${item.description} ${more}`;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
const annotate = (indent: number, item: { note?: string }) => {
|
|
125
|
+
described = {
|
|
126
|
+
indent,
|
|
127
|
+
append: (more) => {
|
|
128
|
+
item.note = item.note === undefined ? more : `${item.note} ${more}`;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const lines = source.split("\n");
|
|
133
|
+
for (const [index, row] of lines.entries()) {
|
|
134
|
+
const line = index + 1;
|
|
135
|
+
const fail = (reason: string): ServicesParse => ({
|
|
136
|
+
ok: false,
|
|
137
|
+
line,
|
|
138
|
+
reason,
|
|
139
|
+
});
|
|
140
|
+
if (row.trim().length === 0 || row.trim().startsWith("%%")) continue;
|
|
141
|
+
// A diff's first column is its sign, so indentation counts from column 1.
|
|
142
|
+
if (diff && !/^[+ -]/.test(row))
|
|
143
|
+
return fail("Start every line of a services diff with +, -, or a space.");
|
|
144
|
+
// SAFETY: the test above limits a diff row's first character to a Sign.
|
|
145
|
+
const own: Sign = diff ? (row[0] as Sign) : " ";
|
|
146
|
+
const raw = diff ? row.slice(1) : row;
|
|
147
|
+
const text = raw.trim();
|
|
148
|
+
if (text.length === 0) continue;
|
|
149
|
+
const indent = raw.replace(/\t/g, " ").search(/\S/);
|
|
150
|
+
// An item takes the sign of the service or step it sits in.
|
|
151
|
+
const signWithin = (
|
|
152
|
+
container: { sign: Sign } | undefined,
|
|
153
|
+
kind: string,
|
|
154
|
+
) => {
|
|
155
|
+
const outer = container?.sign ?? " ";
|
|
156
|
+
if (outer === " " || own === " " || own === outer)
|
|
157
|
+
return outer === " " ? own : outer;
|
|
158
|
+
return fail(
|
|
159
|
+
`A ${own} line can't sit inside a ${outer === "-" ? "removed" : "added"} ${kind}.`,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
if (text.startsWith(">")) {
|
|
163
|
+
if (described === undefined || indent <= described.indent)
|
|
164
|
+
return fail(
|
|
165
|
+
"Put a > line directly below the service, state, step, event, or update it describes, indented further.",
|
|
166
|
+
);
|
|
167
|
+
described.append(text.slice(1).trim());
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const { text: body, references } = parseAnnotation(text);
|
|
171
|
+
const annotation =
|
|
172
|
+
references.length === 0 ? undefined : { text: body.trim(), references };
|
|
173
|
+
const step = steps.at(-1);
|
|
174
|
+
|
|
175
|
+
const serviceMatch = serviceLine.exec(body.trim());
|
|
176
|
+
if (serviceMatch !== null) {
|
|
177
|
+
if (step !== undefined)
|
|
178
|
+
return fail("Declare every service before the first step.");
|
|
179
|
+
const serviceName = serviceMatch[1]!;
|
|
180
|
+
if (byName.has(serviceName))
|
|
181
|
+
return fail(`Service ${serviceName} is declared twice.`);
|
|
182
|
+
while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
|
|
183
|
+
const sign = signWithin(open.at(-1)?.service, "service");
|
|
184
|
+
if (typeof sign !== "string") return sign;
|
|
185
|
+
const service: Service = {
|
|
186
|
+
name: serviceName,
|
|
187
|
+
label: serviceMatch[2],
|
|
188
|
+
description: undefined,
|
|
189
|
+
state: [],
|
|
190
|
+
children: [],
|
|
191
|
+
annotation:
|
|
192
|
+
annotation === undefined
|
|
193
|
+
? undefined
|
|
194
|
+
: { ...annotation, text: serviceName },
|
|
195
|
+
sign,
|
|
196
|
+
};
|
|
197
|
+
(open.at(-1)?.service.children ?? services).push(service);
|
|
198
|
+
byName.set(serviceName, service);
|
|
199
|
+
open.push({ indent, service });
|
|
200
|
+
describe(indent, service);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const stateMatch = stateLine.exec(text);
|
|
205
|
+
if (stateMatch !== null) {
|
|
206
|
+
if (step !== undefined)
|
|
207
|
+
return fail(
|
|
208
|
+
"Declare state under its service, before the first step. Change it in a step with Service.key = value.",
|
|
209
|
+
);
|
|
210
|
+
while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
|
|
211
|
+
const owner = open.at(-1)?.service;
|
|
212
|
+
if (owner === undefined)
|
|
213
|
+
return fail("Indent state under the service that owns it.");
|
|
214
|
+
const key = stateMatch[1]!;
|
|
215
|
+
const value = stateMatch[2]!.trim();
|
|
216
|
+
const sign = signWithin(owner, "service");
|
|
217
|
+
if (typeof sign !== "string") return sign;
|
|
218
|
+
const declared = owner.state.find((entry) => entry.key === key);
|
|
219
|
+
if (declared !== undefined) {
|
|
220
|
+
// A `-` and a `+` declaration of one key change its initial value.
|
|
221
|
+
if (
|
|
222
|
+
declared.todayValue !== undefined ||
|
|
223
|
+
declared.sign === " " ||
|
|
224
|
+
sign === " " ||
|
|
225
|
+
declared.sign === sign
|
|
226
|
+
)
|
|
227
|
+
return fail(`${owner.name}.${key} is declared twice.`);
|
|
228
|
+
declared.todayValue = declared.sign === "-" ? declared.value : value;
|
|
229
|
+
declared.value = declared.sign === "+" ? declared.value : value;
|
|
230
|
+
declared.sign = " ";
|
|
231
|
+
describe(indent, declared);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const entry: ServiceState = {
|
|
235
|
+
key,
|
|
236
|
+
value,
|
|
237
|
+
todayValue: undefined,
|
|
238
|
+
description: undefined,
|
|
239
|
+
sign,
|
|
240
|
+
};
|
|
241
|
+
owner.state.push(entry);
|
|
242
|
+
describe(indent, entry);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const startMatch = startLine.exec(text);
|
|
247
|
+
if (startMatch !== null) {
|
|
248
|
+
if (indent !== 0) return fail("Start the start line at column 0.");
|
|
249
|
+
if (step !== undefined || start !== undefined)
|
|
250
|
+
return fail("Write one start line, before the first step.");
|
|
251
|
+
if (own !== " ")
|
|
252
|
+
return fail("The start line describes both versions; drop its sign.");
|
|
253
|
+
const caption = startMatch[1]?.trim();
|
|
254
|
+
if (caption === undefined || caption === "")
|
|
255
|
+
return fail(
|
|
256
|
+
'Give the start a caption that says what the initial state shows, like start "Alice is signed out".',
|
|
257
|
+
);
|
|
258
|
+
start = { caption, description: undefined };
|
|
259
|
+
describe(indent, start);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const stepMatch = stepLine.exec(text);
|
|
264
|
+
if (stepMatch !== null) {
|
|
265
|
+
if (indent !== 0) return fail("Start step lines at column 0.");
|
|
266
|
+
const caption = stepMatch[1]?.trim();
|
|
267
|
+
if (caption === undefined || caption === "")
|
|
268
|
+
return fail(
|
|
269
|
+
'Give the step a caption that says what happens, like step "Auth checks the password".',
|
|
270
|
+
);
|
|
271
|
+
const next: ServiceStep = {
|
|
272
|
+
caption,
|
|
273
|
+
description: undefined,
|
|
274
|
+
events: [],
|
|
275
|
+
updates: [],
|
|
276
|
+
sign: own,
|
|
277
|
+
};
|
|
278
|
+
steps.push(next);
|
|
279
|
+
describe(indent, next);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (step !== undefined && indent > 0) {
|
|
284
|
+
const event = eventLine.exec(body.trim());
|
|
285
|
+
if (event !== null) {
|
|
286
|
+
for (const service of [event[1]!, event[2]!])
|
|
287
|
+
if (!byName.has(service)) return fail(`Unknown service ${service}.`);
|
|
288
|
+
const label = (event[3] ?? "").trim();
|
|
289
|
+
const sign = signWithin(step, "step");
|
|
290
|
+
if (typeof sign !== "string") return sign;
|
|
291
|
+
const entry: ServiceEvent = {
|
|
292
|
+
from: event[1]!,
|
|
293
|
+
to: event[2]!,
|
|
294
|
+
label,
|
|
295
|
+
note: undefined,
|
|
296
|
+
annotation:
|
|
297
|
+
annotation === undefined
|
|
298
|
+
? undefined
|
|
299
|
+
: {
|
|
300
|
+
...annotation,
|
|
301
|
+
text: `${event[1]} → ${event[2]}${label === "" ? "" : `: ${label}`}`,
|
|
302
|
+
},
|
|
303
|
+
sign,
|
|
304
|
+
};
|
|
305
|
+
step.events.push(entry);
|
|
306
|
+
annotate(indent, entry);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const update = updateLine.exec(text);
|
|
310
|
+
if (update !== null) {
|
|
311
|
+
const owner = byName.get(update[1]!);
|
|
312
|
+
if (owner === undefined) return fail(`Unknown service ${update[1]}.`);
|
|
313
|
+
if (!owner.state.some((entry) => entry.key === update[2]))
|
|
314
|
+
return fail(
|
|
315
|
+
`${owner.name} has no state ${update[2]}. Declare it with state ${update[2]} = <initial value>.`,
|
|
316
|
+
);
|
|
317
|
+
const sign = signWithin(step, "step");
|
|
318
|
+
if (typeof sign !== "string") return sign;
|
|
319
|
+
const entry: ServiceUpdate = {
|
|
320
|
+
service: owner.name,
|
|
321
|
+
key: update[2]!,
|
|
322
|
+
value: update[3]!.trim(),
|
|
323
|
+
note: undefined,
|
|
324
|
+
sign,
|
|
325
|
+
};
|
|
326
|
+
step.updates.push(entry);
|
|
327
|
+
annotate(indent, entry);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
return fail(
|
|
331
|
+
`Expected an event (From -> To: label) or a state update (Service.key = value), got "${text}".`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return fail(
|
|
336
|
+
`Expected service <Name>, state <key> = <value>, start "<caption>", or step "<caption>", got "${text}".`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (services.length === 0)
|
|
340
|
+
return { ok: false, line: 1, reason: "Declare at least one service." };
|
|
341
|
+
return { ok: true, diagram: { services, start, steps, diff } };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Every service in declaration order, with its parent. */
|
|
345
|
+
export function flattenServices(
|
|
346
|
+
services: Service[],
|
|
347
|
+
parent?: Service,
|
|
348
|
+
): { service: Service; parent: Service | undefined }[] {
|
|
349
|
+
return services.flatMap((service) => [
|
|
350
|
+
{ service, parent },
|
|
351
|
+
...flattenServices(service.children, service),
|
|
352
|
+
]);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* State after the first `step` steps; step 0 is the declared state. In a
|
|
357
|
+
* diff, Today skips `+` items and Proposed skips `-` items.
|
|
358
|
+
*/
|
|
359
|
+
export function serviceStateAt(
|
|
360
|
+
diagram: ServiceDiagram,
|
|
361
|
+
step: number,
|
|
362
|
+
side: Side = "proposed",
|
|
363
|
+
) {
|
|
364
|
+
const skip: Sign = side === "today" ? "+" : "-";
|
|
365
|
+
const state = new Map(
|
|
366
|
+
flattenServices(diagram.services).map(({ service }) => [
|
|
367
|
+
service.name,
|
|
368
|
+
new Map(
|
|
369
|
+
service.state
|
|
370
|
+
.filter((entry) => entry.sign !== skip)
|
|
371
|
+
.map((entry) => [
|
|
372
|
+
entry.key,
|
|
373
|
+
side === "today" ? (entry.todayValue ?? entry.value) : entry.value,
|
|
374
|
+
]),
|
|
375
|
+
),
|
|
376
|
+
]),
|
|
377
|
+
);
|
|
378
|
+
for (const { updates } of diagram.steps.slice(0, step))
|
|
379
|
+
for (const update of updates)
|
|
380
|
+
if (update.sign !== skip)
|
|
381
|
+
state.get(update.service)!.set(update.key, update.value);
|
|
382
|
+
return state;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function serviceAnnotations(diagram: ServiceDiagram) {
|
|
386
|
+
return [
|
|
387
|
+
...flattenServices(diagram.services).flatMap(({ service }) =>
|
|
388
|
+
service.annotation === undefined ? [] : [service.annotation],
|
|
389
|
+
),
|
|
390
|
+
...diagram.steps.flatMap((step) =>
|
|
391
|
+
step.events.flatMap((event) =>
|
|
392
|
+
event.annotation === undefined ? [] : [event.annotation],
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
];
|
|
396
|
+
}
|
package/src/share.ts
CHANGED
|
@@ -45,7 +45,7 @@ export async function shareMarkdownFile(input: {
|
|
|
45
45
|
const changes = await readDocumentChanges(input.workspaceRoot, parsed);
|
|
46
46
|
if (changes instanceof Error) return changes;
|
|
47
47
|
// An uncommitted changes card shows the tree as it is when you share.
|
|
48
|
-
for (const rev of parsed.
|
|
48
|
+
for (const { rev } of parsed.changeCards) {
|
|
49
49
|
if (changes.has(rev)) continue;
|
|
50
50
|
const loaded = await readChanges(input.workspaceRoot, rev);
|
|
51
51
|
if (loaded instanceof Error) return loaded;
|
|
@@ -1,33 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
backgroundColor,
|
|
3
|
-
colors,
|
|
4
|
-
focusRing,
|
|
5
|
-
LARGE_SCALE,
|
|
6
|
-
spacing,
|
|
7
|
-
text,
|
|
8
|
-
} from "maui";
|
|
1
|
+
import { colors, focusRing, LARGE_SCALE, text, Tooltip } from "maui";
|
|
9
2
|
import { defineVars, style, useStyles } from "purse-styles";
|
|
10
3
|
import { codeFontFamily } from "../codeFont.js";
|
|
11
|
-
import
|
|
12
|
-
|
|
4
|
+
import {
|
|
5
|
+
callStackRow,
|
|
6
|
+
type SourceAnnotation,
|
|
7
|
+
type SourceNavigation,
|
|
8
|
+
} from "../annotations.js";
|
|
9
|
+
import { lineSymbolMatches, type CodeNote } from "../codeNotes.js";
|
|
10
|
+
import { NoteText, NoteTokens } from "./notes.tsx";
|
|
11
|
+
import { fenceTitle, pierreShell } from "./pierre.js";
|
|
13
12
|
|
|
14
13
|
export function CallStackDiff(
|
|
15
|
-
props: {
|
|
14
|
+
props: {
|
|
15
|
+
lines: SourceAnnotation[];
|
|
16
|
+
highlights: number[];
|
|
17
|
+
notes: CodeNote[];
|
|
18
|
+
title: string | undefined;
|
|
19
|
+
} & SourceNavigation,
|
|
16
20
|
) {
|
|
17
21
|
const shell = useStyles(pierreShell, styles.shell);
|
|
18
|
-
const
|
|
22
|
+
const titleClassName = useStyles(fenceTitle);
|
|
23
|
+
const rowClassName = useStyles(styles.row);
|
|
19
24
|
return (
|
|
20
|
-
<
|
|
25
|
+
<figure className={shell} data-diffmap-kind="callstack">
|
|
26
|
+
{props.title !== undefined && (
|
|
27
|
+
<figcaption className={titleClassName}>{props.title}</figcaption>
|
|
28
|
+
)}
|
|
21
29
|
{props.lines.map((line, index) => {
|
|
22
|
-
const sign = line.text
|
|
23
|
-
? "+"
|
|
24
|
-
: line.text.startsWith("-")
|
|
25
|
-
? "-"
|
|
26
|
-
: " ";
|
|
27
|
-
const label = line.text.replace(/^[+ -]/, "");
|
|
28
|
-
const tree = /^[ \t│┃├└─]*/u.exec(label)![0];
|
|
29
|
-
const description = label.slice(tree.length);
|
|
30
|
+
const { sign, tree, description } = callStackRow(line.text);
|
|
30
31
|
const comment = description.indexOf("#");
|
|
32
|
+
const linked = line.references.length > 0;
|
|
33
|
+
const row = index + 1;
|
|
34
|
+
const lineNote = props.notes.find(
|
|
35
|
+
(note) =>
|
|
36
|
+
note.target.kind === "lines" &&
|
|
37
|
+
note.target.start <= row &&
|
|
38
|
+
row <= note.target.end,
|
|
39
|
+
);
|
|
40
|
+
const matches = lineSymbolMatches(description, props.notes);
|
|
41
|
+
const rowProps = {
|
|
42
|
+
className: rowClassName,
|
|
43
|
+
"data-change": sign,
|
|
44
|
+
"data-highlighted": props.highlights.includes(row) || undefined,
|
|
45
|
+
"data-note":
|
|
46
|
+
lineNote === undefined
|
|
47
|
+
? undefined
|
|
48
|
+
: lineNote.target.kind === "lines" &&
|
|
49
|
+
lineNote.target.start === row
|
|
50
|
+
? "start"
|
|
51
|
+
: "",
|
|
52
|
+
// A native title would show beside the note tooltip.
|
|
53
|
+
title:
|
|
54
|
+
lineNote === undefined && matches.length === 0
|
|
55
|
+
? description
|
|
56
|
+
: undefined,
|
|
57
|
+
};
|
|
31
58
|
const content = (
|
|
32
59
|
<>
|
|
33
60
|
<span className="diff-sign" aria-hidden="true">
|
|
@@ -40,12 +67,21 @@ export function CallStackDiff(
|
|
|
40
67
|
))}
|
|
41
68
|
</span>
|
|
42
69
|
<span className="stack-label">
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
<NoteTokens
|
|
71
|
+
tokens={
|
|
72
|
+
comment === -1
|
|
73
|
+
? [{ content: description }]
|
|
74
|
+
: [
|
|
75
|
+
{ content: description.slice(0, comment) },
|
|
76
|
+
{
|
|
77
|
+
content: description.slice(comment),
|
|
78
|
+
className: "stack-comment",
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
matches={matches}
|
|
83
|
+
focusable={!linked}
|
|
84
|
+
/>
|
|
49
85
|
</span>
|
|
50
86
|
</span>
|
|
51
87
|
{line.references.length > 0 && (
|
|
@@ -55,55 +91,59 @@ export function CallStackDiff(
|
|
|
55
91
|
)}
|
|
56
92
|
</>
|
|
57
93
|
);
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div
|
|
61
|
-
key={index}
|
|
62
|
-
className={row}
|
|
63
|
-
data-change={sign}
|
|
64
|
-
title={description}
|
|
65
|
-
>
|
|
66
|
-
{content}
|
|
67
|
-
</div>
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
return (
|
|
94
|
+
const element = linked ? (
|
|
71
95
|
<button
|
|
72
96
|
key={index}
|
|
73
97
|
type="button"
|
|
74
|
-
|
|
75
|
-
data-change={sign}
|
|
98
|
+
{...rowProps}
|
|
76
99
|
aria-pressed={props.selectedAnnotation === line}
|
|
77
100
|
aria-controls="source-diff-panel"
|
|
78
|
-
title={description}
|
|
79
101
|
aria-label={description}
|
|
80
102
|
onClick={() => props.onSelectAnnotation(line)}
|
|
81
103
|
>
|
|
82
104
|
{content}
|
|
83
105
|
</button>
|
|
106
|
+
) : (
|
|
107
|
+
<div
|
|
108
|
+
key={index}
|
|
109
|
+
{...rowProps}
|
|
110
|
+
tabIndex={lineNote === undefined ? undefined : 0}
|
|
111
|
+
>
|
|
112
|
+
{content}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
if (lineNote === undefined) return element;
|
|
116
|
+
return (
|
|
117
|
+
<Tooltip key={index} content={<NoteText text={lineNote.text} />}>
|
|
118
|
+
{element}
|
|
119
|
+
</Tooltip>
|
|
84
120
|
);
|
|
85
121
|
})}
|
|
86
|
-
</
|
|
122
|
+
</figure>
|
|
87
123
|
);
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
const rowSize = defineVars({
|
|
91
|
-
height: { default: "
|
|
127
|
+
height: { default: "22px", [LARGE_SCALE]: "32px" },
|
|
92
128
|
});
|
|
93
129
|
|
|
94
130
|
const styles = {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
131
|
+
// Sized like Maui's code block, so stacks and code read as one family.
|
|
132
|
+
shell: style(text({ size: "xs", fontWeight: 400, color: "highContrast" }), {
|
|
133
|
+
fontFamily: codeFontFamily,
|
|
134
|
+
margin: 0,
|
|
135
|
+
paddingBlock: "8px",
|
|
136
|
+
overflowX: "hidden",
|
|
137
|
+
maxWidth: "100%",
|
|
138
|
+
// The shell's top padding goes below the title, between it and the rows.
|
|
139
|
+
"& > figcaption": {
|
|
140
|
+
marginTop: "-8px",
|
|
141
|
+
marginBottom: "8px",
|
|
102
142
|
},
|
|
103
|
-
),
|
|
143
|
+
}),
|
|
104
144
|
row: style(
|
|
105
|
-
spacing.padding({ x: 3 }),
|
|
106
145
|
{
|
|
146
|
+
paddingInline: "12px",
|
|
107
147
|
display: "flex",
|
|
108
148
|
alignItems: "center",
|
|
109
149
|
gap: "12px",
|
|
@@ -120,6 +160,30 @@ const styles = {
|
|
|
120
160
|
border: 0,
|
|
121
161
|
"&[data-change='+']": { backgroundColor: colors.green[3] },
|
|
122
162
|
"&[data-change='-']": { backgroundColor: colors.red[3] },
|
|
163
|
+
"&[data-highlighted]": {
|
|
164
|
+
backgroundColor: colors.blue[3],
|
|
165
|
+
boxShadow: `inset 3px 0 ${colors.blue[9]}`,
|
|
166
|
+
},
|
|
167
|
+
"&[data-change='+'][data-highlighted]": {
|
|
168
|
+
backgroundColor: colors.green[5],
|
|
169
|
+
boxShadow: `inset 3px 0 ${colors.green[9]}`,
|
|
170
|
+
},
|
|
171
|
+
"&[data-change='-'][data-highlighted]": {
|
|
172
|
+
backgroundColor: colors.red[5],
|
|
173
|
+
boxShadow: `inset 3px 0 ${colors.red[9]}`,
|
|
174
|
+
},
|
|
175
|
+
"&[data-note]": { cursor: "help" },
|
|
176
|
+
// The sign fills the row gutter, so the dot follows the label instead.
|
|
177
|
+
"&[data-note='start'] .stack-label::after": {
|
|
178
|
+
content: '""',
|
|
179
|
+
display: "inline-block",
|
|
180
|
+
width: "5px",
|
|
181
|
+
height: "5px",
|
|
182
|
+
marginLeft: "8px",
|
|
183
|
+
verticalAlign: "middle",
|
|
184
|
+
borderRadius: "50%",
|
|
185
|
+
backgroundColor: colors.blue[9],
|
|
186
|
+
},
|
|
123
187
|
"&[aria-pressed]": { cursor: "pointer" },
|
|
124
188
|
"&[aria-pressed]:hover": { backgroundColor: colors.blue[2] },
|
|
125
189
|
"&[aria-pressed='true'], &[aria-pressed='true']:hover": {
|
|
@@ -138,7 +202,7 @@ const styles = {
|
|
|
138
202
|
alignSelf: "center",
|
|
139
203
|
flex: "1 1 auto",
|
|
140
204
|
minWidth: 0,
|
|
141
|
-
paddingBlock: "
|
|
205
|
+
paddingBlock: "1px",
|
|
142
206
|
overflow: "hidden",
|
|
143
207
|
textOverflow: "ellipsis",
|
|
144
208
|
whiteSpace: "nowrap",
|