@tanishqkancharla/diffmap 0.3.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 +97 -1
- package/index.html +6 -0
- package/package.json +3 -2
- package/src/changes.ts +15 -0
- package/src/codeNotes.ts +123 -0
- package/src/errors.ts +5 -0
- 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 +148 -102
- package/src/host/home.test.ts +6 -7
- package/src/parseFence.ts +57 -17
- package/src/parseViewer.test.ts +216 -2
- package/src/parseViewer.ts +85 -19
- package/src/services.ts +289 -0
- package/src/share.ts +1 -1
- 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 +11 -8
- 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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { type KeyboardEvent, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useReducedMotion } from "motion/react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
ChevronLeft,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Pause,
|
|
8
|
+
Play,
|
|
9
|
+
RotateAntiClockwise,
|
|
10
|
+
Text,
|
|
11
|
+
Tooltip,
|
|
12
|
+
borderColor,
|
|
13
|
+
colors,
|
|
14
|
+
flex,
|
|
15
|
+
focusRing,
|
|
16
|
+
radius,
|
|
17
|
+
spacing,
|
|
18
|
+
text,
|
|
19
|
+
} from "maui";
|
|
20
|
+
import { style, useStyles } from "purse-styles";
|
|
21
|
+
import type { SourceNavigation } from "../annotations.js";
|
|
22
|
+
import { ServiceStage, TRAVEL_MS } from "./ServiceStage.tsx";
|
|
23
|
+
import {
|
|
24
|
+
serviceStateAt,
|
|
25
|
+
type ServiceDiagram,
|
|
26
|
+
type ServicesParse,
|
|
27
|
+
} from "../services.js";
|
|
28
|
+
|
|
29
|
+
const DWELL_MS = 1200;
|
|
30
|
+
|
|
31
|
+
type Phase = "travel" | "settled";
|
|
32
|
+
|
|
33
|
+
export function ServiceDiagramBlock(
|
|
34
|
+
props: { source: string; parsed: ServicesParse } & SourceNavigation,
|
|
35
|
+
) {
|
|
36
|
+
const error = useStyles(styles.error);
|
|
37
|
+
if (!props.parsed.ok)
|
|
38
|
+
return (
|
|
39
|
+
<div className={error} data-diffmap-kind="services">
|
|
40
|
+
Line {props.parsed.line}: {props.parsed.reason}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
// Remount on edits so playback restarts and the stage measures new boxes.
|
|
44
|
+
return (
|
|
45
|
+
<ServicePlayer
|
|
46
|
+
key={props.source}
|
|
47
|
+
{...props}
|
|
48
|
+
diagram={props.parsed.diagram}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
54
|
+
const diagram = props.diagram;
|
|
55
|
+
const last = diagram.steps.length;
|
|
56
|
+
const reduceMotion = Boolean(useReducedMotion());
|
|
57
|
+
const [position, setPosition] = useState<{ step: number; phase: Phase }>({
|
|
58
|
+
step: 0,
|
|
59
|
+
phase: "settled",
|
|
60
|
+
});
|
|
61
|
+
const [playing, setPlaying] = useState(false);
|
|
62
|
+
const states = useMemo(
|
|
63
|
+
() =>
|
|
64
|
+
Array.from({ length: last + 1 }, (_, step) =>
|
|
65
|
+
serviceStateAt(diagram, step),
|
|
66
|
+
),
|
|
67
|
+
[diagram, last],
|
|
68
|
+
);
|
|
69
|
+
const step = position.step;
|
|
70
|
+
const current = step === 0 ? undefined : diagram.steps[step - 1]!;
|
|
71
|
+
const settled = position.phase === "settled";
|
|
72
|
+
// In flight, the events have not landed, so show the previous state.
|
|
73
|
+
const shownState = states[settled ? step : step - 1]!;
|
|
74
|
+
const changed = new Set(
|
|
75
|
+
settled && current !== undefined
|
|
76
|
+
? current.updates.map((update) => `${update.service}.${update.key}`)
|
|
77
|
+
: [],
|
|
78
|
+
);
|
|
79
|
+
const active = new Set(
|
|
80
|
+
current?.events.flatMap((event) => [event.from, event.to]) ?? [],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const goTo = (target: number, animate: boolean) =>
|
|
84
|
+
setPosition(positionFor(diagram, target, animate && !reduceMotion));
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (position.phase !== "travel") return;
|
|
88
|
+
const timer = setTimeout(
|
|
89
|
+
() => setPosition({ step: position.step, phase: "settled" }),
|
|
90
|
+
TRAVEL_MS,
|
|
91
|
+
);
|
|
92
|
+
return () => clearTimeout(timer);
|
|
93
|
+
}, [position]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!playing || position.phase !== "settled") return;
|
|
97
|
+
const timer = setTimeout(
|
|
98
|
+
() => {
|
|
99
|
+
if (position.step >= last) setPlaying(false);
|
|
100
|
+
else
|
|
101
|
+
setPosition(positionFor(diagram, position.step + 1, !reduceMotion));
|
|
102
|
+
},
|
|
103
|
+
position.step === 0 ? 300 : DWELL_MS,
|
|
104
|
+
);
|
|
105
|
+
return () => clearTimeout(timer);
|
|
106
|
+
}, [playing, position, last, diagram, reduceMotion]);
|
|
107
|
+
|
|
108
|
+
const prev = () => {
|
|
109
|
+
setPlaying(false);
|
|
110
|
+
if (step > 0) goTo(step - 1, false);
|
|
111
|
+
};
|
|
112
|
+
const next = () => {
|
|
113
|
+
setPlaying(false);
|
|
114
|
+
if (step < last) goTo(step + 1, true);
|
|
115
|
+
};
|
|
116
|
+
const togglePlay = () => {
|
|
117
|
+
if (playing) return setPlaying(false);
|
|
118
|
+
if (step >= last) goTo(0, false);
|
|
119
|
+
setPlaying(true);
|
|
120
|
+
};
|
|
121
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
122
|
+
if (event.key === "ArrowLeft") {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
prev();
|
|
125
|
+
}
|
|
126
|
+
if (event.key === "ArrowRight") {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
next();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const frame = useStyles(styles.frame);
|
|
133
|
+
const controls = useStyles(styles.controls);
|
|
134
|
+
const scrubber = useStyles(styles.scrubber);
|
|
135
|
+
const segment = useStyles(styles.segment);
|
|
136
|
+
const detail = useStyles(styles.detail);
|
|
137
|
+
const counter = useStyles(styles.counter);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className={frame}
|
|
142
|
+
data-diffmap-kind="services"
|
|
143
|
+
// Capture: Maui's Button stops arrow keys from bubbling.
|
|
144
|
+
onKeyDownCapture={onKeyDown}
|
|
145
|
+
>
|
|
146
|
+
<ServiceStage
|
|
147
|
+
{...props}
|
|
148
|
+
state={shownState}
|
|
149
|
+
previousState={states[Math.max(step - 1, 0)]!}
|
|
150
|
+
changed={changed}
|
|
151
|
+
active={active}
|
|
152
|
+
events={current?.events ?? []}
|
|
153
|
+
updates={current?.updates ?? []}
|
|
154
|
+
phase={position.phase}
|
|
155
|
+
stepKey={step}
|
|
156
|
+
/>
|
|
157
|
+
{last > 0 && (
|
|
158
|
+
<>
|
|
159
|
+
<div className={controls}>
|
|
160
|
+
<Tooltip content="Restart" placement="top">
|
|
161
|
+
<Button
|
|
162
|
+
size="sm"
|
|
163
|
+
variant="quiet"
|
|
164
|
+
aria-label="Restart"
|
|
165
|
+
isDisabled={step === 0 && !playing}
|
|
166
|
+
onClick={() => {
|
|
167
|
+
setPlaying(false);
|
|
168
|
+
goTo(0, false);
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<RotateAntiClockwise size="xs" />
|
|
172
|
+
</Button>
|
|
173
|
+
</Tooltip>
|
|
174
|
+
<Tooltip content="Previous step (←)" placement="top">
|
|
175
|
+
<Button
|
|
176
|
+
size="sm"
|
|
177
|
+
variant="quiet"
|
|
178
|
+
aria-label="Previous step"
|
|
179
|
+
isDisabled={step === 0}
|
|
180
|
+
onClick={prev}
|
|
181
|
+
>
|
|
182
|
+
<ChevronLeft size="xs" />
|
|
183
|
+
</Button>
|
|
184
|
+
</Tooltip>
|
|
185
|
+
<Button
|
|
186
|
+
size="sm"
|
|
187
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
188
|
+
onClick={togglePlay}
|
|
189
|
+
>
|
|
190
|
+
{playing ? <Pause size="xs" /> : <Play size="xs" />}
|
|
191
|
+
{playing ? "Pause" : step >= last ? "Replay" : "Play"}
|
|
192
|
+
</Button>
|
|
193
|
+
<Tooltip content="Next step (→)" placement="top">
|
|
194
|
+
<Button
|
|
195
|
+
size="sm"
|
|
196
|
+
variant="quiet"
|
|
197
|
+
aria-label="Next step"
|
|
198
|
+
isDisabled={step >= last}
|
|
199
|
+
onClick={next}
|
|
200
|
+
>
|
|
201
|
+
<ChevronRight size="xs" />
|
|
202
|
+
</Button>
|
|
203
|
+
</Tooltip>
|
|
204
|
+
<div className={scrubber} role="group" aria-label="Steps">
|
|
205
|
+
{diagram.steps.map((entry, index) => (
|
|
206
|
+
<Tooltip
|
|
207
|
+
key={index}
|
|
208
|
+
placement="top"
|
|
209
|
+
content={
|
|
210
|
+
<>
|
|
211
|
+
<strong>
|
|
212
|
+
{index + 1}. {entry.caption}
|
|
213
|
+
</strong>
|
|
214
|
+
{entry.description !== undefined && (
|
|
215
|
+
<> {entry.description}</>
|
|
216
|
+
)}
|
|
217
|
+
</>
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
className={segment}
|
|
223
|
+
data-state={
|
|
224
|
+
index + 1 === step
|
|
225
|
+
? "current"
|
|
226
|
+
: index + 1 < step
|
|
227
|
+
? "done"
|
|
228
|
+
: "todo"
|
|
229
|
+
}
|
|
230
|
+
aria-label={`Step ${index + 1}: ${entry.caption}`}
|
|
231
|
+
aria-current={index + 1 === step ? "step" : undefined}
|
|
232
|
+
onClick={() => {
|
|
233
|
+
setPlaying(false);
|
|
234
|
+
goTo(index + 1, index + 1 === step + 1);
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
</Tooltip>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
<Text className={counter} size="xs" color="lowContrast" tabular>
|
|
241
|
+
{step} / {last}
|
|
242
|
+
</Text>
|
|
243
|
+
</div>
|
|
244
|
+
<div className={detail} aria-live="polite">
|
|
245
|
+
<Text size="sm" fontWeight={500}>
|
|
246
|
+
{current?.caption ?? "Start"}
|
|
247
|
+
</Text>
|
|
248
|
+
{current === undefined && (
|
|
249
|
+
<Text size="xs" color="lowContrast">
|
|
250
|
+
Initial state. Press Play, or step with ← and →.
|
|
251
|
+
</Text>
|
|
252
|
+
)}
|
|
253
|
+
{current?.description !== undefined && (
|
|
254
|
+
<Text size="xs" color="lowContrast">
|
|
255
|
+
{current.description}
|
|
256
|
+
</Text>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function positionFor(
|
|
266
|
+
diagram: ServiceDiagram,
|
|
267
|
+
step: number,
|
|
268
|
+
animate: boolean,
|
|
269
|
+
): { step: number; phase: Phase } {
|
|
270
|
+
const events = diagram.steps[step - 1]?.events.length ?? 0;
|
|
271
|
+
return { step, phase: animate && events > 0 ? "travel" : "settled" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const styles = {
|
|
275
|
+
error: style(text({ size: "sm", color: "lowContrast" })),
|
|
276
|
+
frame: style(
|
|
277
|
+
flex({ direction: "column", gap: 4 }),
|
|
278
|
+
radius.lg,
|
|
279
|
+
spacing.padding({ all: 6 }),
|
|
280
|
+
{
|
|
281
|
+
width: "100%",
|
|
282
|
+
minWidth: 0,
|
|
283
|
+
border: `1px solid ${borderColor.outline}`,
|
|
284
|
+
},
|
|
285
|
+
),
|
|
286
|
+
controls: style(flex({ direction: "row", alignItems: "center", gap: 2 })),
|
|
287
|
+
scrubber: style(flex({ direction: "row", alignItems: "center", gap: 1 }), {
|
|
288
|
+
flex: "1 1 auto",
|
|
289
|
+
minWidth: 0,
|
|
290
|
+
marginInline: spacing.value(4),
|
|
291
|
+
}),
|
|
292
|
+
segment: style(
|
|
293
|
+
radius.pill,
|
|
294
|
+
{
|
|
295
|
+
flex: "1 1 0",
|
|
296
|
+
minWidth: 6,
|
|
297
|
+
height: 20,
|
|
298
|
+
padding: 0,
|
|
299
|
+
border: 0,
|
|
300
|
+
cursor: "pointer",
|
|
301
|
+
background: "transparent",
|
|
302
|
+
position: "relative",
|
|
303
|
+
"&::after": {
|
|
304
|
+
content: "''",
|
|
305
|
+
position: "absolute",
|
|
306
|
+
insetInline: 0,
|
|
307
|
+
top: 8,
|
|
308
|
+
height: 4,
|
|
309
|
+
borderRadius: 2,
|
|
310
|
+
backgroundColor: colors.gray[5],
|
|
311
|
+
},
|
|
312
|
+
"&:hover::after": { backgroundColor: colors.gray[7] },
|
|
313
|
+
"&[data-state='done']::after": { backgroundColor: colors.accent[7] },
|
|
314
|
+
"&[data-state='current']::after": { backgroundColor: colors.accent[9] },
|
|
315
|
+
},
|
|
316
|
+
focusRing(),
|
|
317
|
+
),
|
|
318
|
+
counter: style({ flexShrink: 0, minWidth: "5ch", textAlign: "end" }),
|
|
319
|
+
detail: style(flex({ direction: "column", gap: 2 }), {
|
|
320
|
+
minHeight: 44,
|
|
321
|
+
paddingInline: spacing.value(2),
|
|
322
|
+
}),
|
|
323
|
+
};
|