@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.
@@ -19,7 +19,7 @@ import {
19
19
  type SourceAnnotation,
20
20
  type SourceReference,
21
21
  } from "../annotations.js";
22
- import { changedFileItem, type Rev } from "../changes.js";
22
+ import { changedFileItem, orderForReview, type Rev } from "../changes.js";
23
23
  import type { SourceDefinition } from "../definitions.js";
24
24
  import { useGistPin } from "../gist/pin.ts";
25
25
  import { useGitHubPin } from "../github/pin.ts";
@@ -46,6 +46,8 @@ type DefinitionNavigation = {
46
46
  export function SourceDiffPanel(props: {
47
47
  /** The changes to show; references with their own rev open that rev instead. */
48
48
  rev: Rev;
49
+ /** Paths to show first, in order, as the rev's changes card lists them. */
50
+ review: string[];
49
51
  /** Local viewers pick other changes with the range picker. */
50
52
  onRevChange?: (rev: Rev) => void;
51
53
  selection: SourceSelection | undefined;
@@ -85,13 +87,14 @@ export function SourceDiffPanel(props: {
85
87
  const selection = props.selection;
86
88
  const reference = selection?.reference;
87
89
  const changes = useChanges(props.rev);
88
- const items = useMemo(
89
- () =>
90
- changes === undefined || changes instanceof Error
91
- ? []
92
- : changes.files.flatMap((changed) => changedFileItem(changed) ?? []),
93
- [changes],
94
- );
90
+ const { review } = props;
91
+ const items = useMemo(() => {
92
+ if (changes === undefined || changes instanceof Error) return [];
93
+ const { reviewed, other } = orderForReview(changes.files, review);
94
+ return [...reviewed, ...other].flatMap(
95
+ (changed) => changedFileItem(changed) ?? [],
96
+ );
97
+ }, [changes, review]);
95
98
  // A rev-less reference resolves against current files; a commit reference names its lines directly.
96
99
  const source = useSourceReference(
97
100
  reference?.kind === "file" && reference.rev === undefined
@@ -112,7 +115,8 @@ export function SourceDiffPanel(props: {
112
115
  ? navigation.status
113
116
  : source instanceof Error
114
117
  ? source.message
115
- : changes instanceof Error
118
+ : // A shown file replaces the changes, so their error is moot, as in a gist without uncommitted changes.
119
+ changes instanceof Error && file === undefined
116
120
  ? changes.message
117
121
  : changes === undefined
118
122
  ? "Loading changes…"
@@ -390,6 +390,9 @@ const styles = {
390
390
  justifyContent: "center",
391
391
  alignItems: "flex-start",
392
392
  pointerEvents: "none",
393
+ "@media (max-width: 600px)": {
394
+ display: "none",
395
+ },
393
396
  }),
394
397
  cluster: style(spacing.padding({ top: 4, bottom: 4, left: 2, right: 6 }), {
395
398
  position: "relative",
@@ -45,7 +45,7 @@ export function ViewerApp(props: {
45
45
  const local = props.mode === "local";
46
46
  const documentRev =
47
47
  props.defaultRev ??
48
- viewerDocument.changeRevs[0] ??
48
+ viewerDocument.changeCards[0]?.rev ??
49
49
  viewerDocument.references.find((ref) => ref.rev !== undefined)?.rev ??
50
50
  (local ? ALL : WORKING);
51
51
  const [panelRev, setPanelRev] = useState(documentRev);
@@ -58,9 +58,14 @@ export function ViewerApp(props: {
58
58
  const hasSourceDiffs =
59
59
  local ||
60
60
  viewerDocument.references.length > 0 ||
61
- viewerDocument.changeRevs.length > 0 ||
61
+ viewerDocument.changeCards.length > 0 ||
62
62
  props.defaultRev !== undefined;
63
63
  const diffPanelOpen = hasSourceDiffs && showDiffPanel;
64
+ // The panel lists files in the order the rev's changes card asks them to be reviewed.
65
+ const panelReview =
66
+ viewerDocument.changeCards.find(
67
+ (card) => card.rev === panelRev && card.review.length > 0,
68
+ )?.review ?? NO_REVIEW;
64
69
  const body = useStyles(styles.body);
65
70
  const [shutDown, setShutDown] = useState(false);
66
71
  const parsedTitle =
@@ -157,6 +162,7 @@ export function ViewerApp(props: {
157
162
  {diffPanelOpen && (
158
163
  <SourceDiffPanel
159
164
  rev={panelRev}
165
+ review={panelReview}
160
166
  onRevChange={
161
167
  local
162
168
  ? (rev) => {
@@ -177,6 +183,8 @@ export function ViewerApp(props: {
177
183
  );
178
184
  }
179
185
 
186
+ const NO_REVIEW: string[] = [];
187
+
180
188
  function useViewerMeta(enabled: boolean) {
181
189
  const [meta, setMeta] = useState<ViewerMeta>();
182
190
  useEffect(() => {
@@ -265,6 +273,10 @@ const styles = {
265
273
  backgroundColor: backgroundColor.app,
266
274
  // Scrollport size for the sticky gutter rail (`100cqh`).
267
275
  containerType: "size",
276
+ "@media (max-width: 600px)": {
277
+ paddingInline: spacing.value(6),
278
+ paddingBlock: spacing.value(8),
279
+ },
268
280
  }),
269
281
  prose: style({
270
282
  display: "grid",
@@ -273,7 +285,10 @@ const styles = {
273
285
  maxWidth: "none",
274
286
  minWidth: 0,
275
287
  "&[data-has-toc='true']": {
276
- gridTemplateColumns: `minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr) minmax(0, ${proseMaxWidth}) minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr)`,
288
+ gridTemplateColumns: `var(--diffmap-toc-columns, minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr) minmax(0, ${proseMaxWidth}) minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr))`,
289
+ },
290
+ "@media (max-width: 600px)": {
291
+ "--diffmap-toc-columns": "0 minmax(0, 1fr) 0",
277
292
  },
278
293
  }),
279
294
  content: style({
@@ -0,0 +1,126 @@
1
+ import type { ReactNode } from "react";
2
+ import { Code, colors, focusRing, Tooltip } from "maui";
3
+ import { style, useStyles } from "purse-styles";
4
+ import type { SymbolMatch } from "../codeNotes.js";
5
+
6
+ export type Token = {
7
+ content: string;
8
+ color?: string;
9
+ fontStyle?: number;
10
+ className?: string;
11
+ };
12
+
13
+ /** Splits tokens at symbol note boundaries and wraps each match in a tooltip. */
14
+ export function NoteTokens(props: {
15
+ tokens: Token[];
16
+ matches: SymbolMatch[];
17
+ /** False inside a button, which cannot contain focusable elements. */
18
+ focusable?: boolean;
19
+ }) {
20
+ const { tokens, matches } = props;
21
+ const symbolClassName = useStyles(symbolNoteClass);
22
+ const cuts = new Set(matches.flatMap((match) => [match.start, match.end]));
23
+ const pieces: { start: number; token: Token }[] = [];
24
+ let column = 0;
25
+ for (const token of tokens) {
26
+ let start = column;
27
+ for (let at = column + 1; at < column + token.content.length; at++) {
28
+ if (!cuts.has(at)) continue;
29
+ pieces.push({
30
+ start,
31
+ token: {
32
+ ...token,
33
+ content: token.content.slice(start - column, at - column),
34
+ },
35
+ });
36
+ start = at;
37
+ }
38
+ pieces.push({
39
+ start,
40
+ token: { ...token, content: token.content.slice(start - column) },
41
+ });
42
+ column += token.content.length;
43
+ }
44
+
45
+ const out: ReactNode[] = [];
46
+ let group: ReactNode[] = [];
47
+ for (const piece of pieces) {
48
+ const span = <TokenSpan key={piece.start} token={piece.token} />;
49
+ const match = matches.find(
50
+ (m) => piece.start >= m.start && piece.start < m.end,
51
+ );
52
+ if (match === undefined) {
53
+ out.push(span);
54
+ continue;
55
+ }
56
+ group.push(span);
57
+ if (piece.start + piece.token.content.length < match.end) continue;
58
+ out.push(
59
+ <Tooltip
60
+ key={`note-${match.start}`}
61
+ content={<NoteText text={match.note.text} />}
62
+ >
63
+ <span
64
+ className={symbolClassName}
65
+ tabIndex={props.focusable === false ? undefined : 0}
66
+ >
67
+ {group}
68
+ </span>
69
+ </Tooltip>,
70
+ );
71
+ group = [];
72
+ }
73
+ return <>{out}</>;
74
+ }
75
+
76
+ function TokenSpan(props: { token: Token }) {
77
+ const fontStyle = props.token.fontStyle ?? 0;
78
+ return (
79
+ <span
80
+ className={props.token.className}
81
+ style={{
82
+ color: props.token.color,
83
+ fontStyle: fontStyle & 1 ? "italic" : undefined,
84
+ fontWeight: fontStyle & 2 ? 500 : undefined,
85
+ textDecoration: fontStyle & 4 ? "underline" : undefined,
86
+ }}
87
+ >
88
+ {props.token.content}
89
+ </span>
90
+ );
91
+ }
92
+
93
+ /** Note text with `backtick` spans as inline code. */
94
+ export function NoteText(props: { text: string }) {
95
+ return props.text
96
+ .split(/(`[^`]+`)/)
97
+ .map((part, index) =>
98
+ part.startsWith("`") && part.endsWith("`") && part.length > 1 ? (
99
+ <Code key={index}>{part.slice(1, -1)}</Code>
100
+ ) : (
101
+ part
102
+ ),
103
+ );
104
+ }
105
+
106
+ const symbolNoteClass = style(focusRing(), {
107
+ borderRadius: "3px",
108
+ cursor: "help",
109
+ textDecorationLine: "underline",
110
+ textDecorationStyle: "dotted",
111
+ textDecorationColor: colors.gray[9],
112
+ textUnderlineOffset: "3px",
113
+ "&:hover": { backgroundColor: colors.blueAlpha[5] },
114
+ });
115
+
116
+ /** A dot in the left gutter of the first row a line note covers. */
117
+ export const lineNoteDot = {
118
+ content: '""',
119
+ position: "absolute",
120
+ left: "4px",
121
+ top: "calc(0.5lh - 2.5px)",
122
+ width: "5px",
123
+ height: "5px",
124
+ borderRadius: "50%",
125
+ backgroundColor: colors.blue[9],
126
+ } as const;
@@ -0,0 +1,189 @@
1
+ import ELK, {
2
+ type ElkExtendedEdge,
3
+ type ElkNode,
4
+ } from "elkjs/lib/elk.bundled.js";
5
+ import {
6
+ flattenServices,
7
+ type Service,
8
+ type ServiceDiagram,
9
+ } from "../services.js";
10
+
11
+ export type Point = { x: number; y: number };
12
+ export type Rect = { x: number; y: number; width: number; height: number };
13
+ export type Size = { width: number; height: number };
14
+
15
+ /**
16
+ * One connection per pair of services that ever talk, pointing from the one
17
+ * declared first. Requests and replies share it, so the graph has no cycles
18
+ * and ELK never routes a reply around the whole diagram.
19
+ */
20
+ export type ServiceLink = {
21
+ id: string;
22
+ a: string;
23
+ b: string;
24
+ /** Some event goes a → b. */
25
+ forward: boolean;
26
+ /** Some event goes b → a. */
27
+ backward: boolean;
28
+ /** The longest label on the link. */
29
+ label: string | undefined;
30
+ };
31
+
32
+ export type ServiceLayout = {
33
+ width: number;
34
+ height: number;
35
+ /** Each service's own header and state, measured before layout. */
36
+ sizes: Map<string, Size>;
37
+ boxes: Map<string, Rect>;
38
+ routes: Map<string, Point[]>;
39
+ };
40
+
41
+ export function serviceLinks(diagram: ServiceDiagram): ServiceLink[] {
42
+ const order = new Map(
43
+ flattenServices(diagram.services).map(({ service }, index) => [
44
+ service.name,
45
+ index,
46
+ ]),
47
+ );
48
+ const links = new Map<string, ServiceLink>();
49
+ for (const step of diagram.steps)
50
+ for (const event of step.events) {
51
+ const forward = order.get(event.from)! <= order.get(event.to)!;
52
+ const [a, b] = forward ? [event.from, event.to] : [event.to, event.from];
53
+ const id = linkId(a, b);
54
+ const link = links.get(id) ?? {
55
+ id,
56
+ a,
57
+ b,
58
+ forward: false,
59
+ backward: false,
60
+ label: undefined,
61
+ };
62
+ if (forward) link.forward = true;
63
+ else link.backward = true;
64
+ // Measure the pill as drawn: a linked event shows ↗.
65
+ const shown =
66
+ event.label !== "" && event.annotation !== undefined
67
+ ? `${event.label} ↗`
68
+ : event.label;
69
+ if (shown.length > (link.label?.length ?? 0)) link.label = shown;
70
+ links.set(id, link);
71
+ }
72
+ return [...links.values()];
73
+ }
74
+
75
+ export function linkId(from: string, to: string) {
76
+ return from < to ? `${from}|${to}` : `${to}|${from}`;
77
+ }
78
+
79
+ const elk = new ELK();
80
+
81
+ function contains(service: Service, name: string): boolean {
82
+ return service.children.some(
83
+ (child) => child.name === name || contains(child, name),
84
+ );
85
+ }
86
+
87
+ /**
88
+ * `sizes` are each service's own header and state; a parent grows around its
89
+ * children. `label` is the largest event label, which must fit between layers.
90
+ */
91
+ export async function layoutServices(input: {
92
+ diagram: ServiceDiagram;
93
+ links: ServiceLink[];
94
+ sizes: Map<string, Size>;
95
+ label: Size;
96
+ direction: "RIGHT" | "DOWN";
97
+ }): Promise<ServiceLayout> {
98
+ // Room for a label pill on the edge between two layers. Each parent lays out
99
+ // its own children, so parents need it too.
100
+ const betweenLayers = String(
101
+ input.direction === "RIGHT"
102
+ ? Math.max(40, input.label.width + 24)
103
+ : Math.max(40, input.label.height + 28),
104
+ );
105
+ const node = (service: Service): ElkNode => {
106
+ const own = input.sizes.get(service.name)!;
107
+ if (service.children.length === 0)
108
+ return { id: service.name, width: own.width, height: own.height };
109
+ // A parent that talks to its own children needs room for that route and
110
+ // its label between its header and the children.
111
+ const inner = input.links.some(
112
+ (link) => link.a === service.name && contains(service, link.b),
113
+ );
114
+ const room = (size: number) => (inner ? size + 24 : 0);
115
+ const top =
116
+ own.height +
117
+ 4 +
118
+ (input.direction === "DOWN" ? room(input.label.height) : 0);
119
+ const left =
120
+ 16 + (input.direction === "RIGHT" ? room(input.label.width) : 0);
121
+ return {
122
+ id: service.name,
123
+ layoutOptions: {
124
+ "elk.padding": `[top=${top},left=${left},bottom=16,right=16]`,
125
+ "elk.nodeSize.constraints": "MINIMUM_SIZE",
126
+ "elk.layered.spacing.nodeNodeBetweenLayers": betweenLayers,
127
+ // Wide enough for its own header. ELK applies this before rotating
128
+ // a top-to-bottom layout, so the axes swap.
129
+ "elk.nodeSize.minimum":
130
+ input.direction === "DOWN" ? `(0,${own.width})` : `(${own.width},0)`,
131
+ },
132
+ children: service.children.map(node),
133
+ };
134
+ };
135
+ const graph: ElkNode = {
136
+ id: "root",
137
+ layoutOptions: {
138
+ "elk.algorithm": "layered",
139
+ "elk.direction": input.direction,
140
+ "elk.edgeRouting": "ORTHOGONAL",
141
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
142
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
143
+ "elk.json.edgeCoords": "ROOT",
144
+ "elk.json.shapeCoords": "ROOT",
145
+ "elk.padding": "[top=8,left=8,bottom=8,right=8]",
146
+ "elk.spacing.nodeNode": "32",
147
+ "elk.layered.spacing.nodeNodeBetweenLayers": betweenLayers,
148
+ "elk.spacing.edgeNode": "16",
149
+ "elk.spacing.edgeEdge": "12",
150
+ },
151
+ children: input.diagram.services.map(node),
152
+ edges: input.links.map((link): ElkExtendedEdge => ({
153
+ id: link.id,
154
+ sources: [link.a],
155
+ targets: [link.b],
156
+ })),
157
+ };
158
+ const result = await elk.layout(graph);
159
+
160
+ const boxes = new Map<string, Rect>();
161
+ const visit = (nodes: ElkNode[] | undefined) => {
162
+ for (const child of nodes ?? []) {
163
+ boxes.set(child.id, {
164
+ x: child.x!,
165
+ y: child.y!,
166
+ width: child.width!,
167
+ height: child.height!,
168
+ });
169
+ visit(child.children);
170
+ }
171
+ };
172
+ visit(result.children);
173
+ const routes = new Map(
174
+ (result.edges ?? []).map((edge) => {
175
+ const section = edge.sections![0]!;
176
+ return [
177
+ edge.id,
178
+ [section.startPoint, ...(section.bendPoints ?? []), section.endPoint],
179
+ ];
180
+ }),
181
+ );
182
+ return {
183
+ width: result.width!,
184
+ height: result.height!,
185
+ sizes: input.sizes,
186
+ boxes,
187
+ routes,
188
+ };
189
+ }
package/vite.config.ts CHANGED
@@ -26,6 +26,7 @@ export default defineConfig(({ command }) => {
26
26
  "purse-styles",
27
27
  "@pierre/diffs/react",
28
28
  "beautiful-mermaid",
29
+ "elkjs/lib/elk.bundled.js",
29
30
  "errore",
30
31
  ],
31
32
  },
@@ -22,6 +22,7 @@ export function gistAppConfig(): UserConfig {
22
22
  "purse-styles",
23
23
  "@pierre/diffs/react",
24
24
  "beautiful-mermaid",
25
+ "elkjs/lib/elk.bundled.js",
25
26
  "errore",
26
27
  "md4x/standalone",
27
28
  ],