@tanishqkancharla/diffmap 0.4.0 → 0.6.1

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.
@@ -1,39 +1,33 @@
1
- import {
2
- backgroundColor,
3
- colors,
4
- focusRing,
5
- LARGE_SCALE,
6
- spacing,
7
- text,
8
- Tooltip,
9
- } from "maui";
1
+ import { colors, focusRing, LARGE_SCALE, text, Tooltip } from "maui";
10
2
  import { defineVars, style, useStyles } from "purse-styles";
11
3
  import { codeFontFamily } from "../codeFont.js";
12
- import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
4
+ import {
5
+ callStackRow,
6
+ type SourceAnnotation,
7
+ type SourceNavigation,
8
+ } from "../annotations.js";
13
9
  import { lineSymbolMatches, type CodeNote } from "../codeNotes.js";
14
10
  import { NoteText, NoteTokens } from "./notes.tsx";
15
- import { pierreShell } from "./pierre.js";
11
+ import { fenceTitle, pierreShell } from "./pierre.js";
16
12
 
17
13
  export function CallStackDiff(
18
14
  props: {
19
15
  lines: SourceAnnotation[];
20
16
  highlights: number[];
21
17
  notes: CodeNote[];
18
+ title: string | undefined;
22
19
  } & SourceNavigation,
23
20
  ) {
24
21
  const shell = useStyles(pierreShell, styles.shell);
22
+ const titleClassName = useStyles(fenceTitle);
25
23
  const rowClassName = useStyles(styles.row);
26
24
  return (
27
- <div className={shell} data-diffmap-kind="callstack">
25
+ <figure className={shell} data-diffmap-kind="callstack">
26
+ {props.title !== undefined && (
27
+ <figcaption className={titleClassName}>{props.title}</figcaption>
28
+ )}
28
29
  {props.lines.map((line, index) => {
29
- const sign = line.text.startsWith("+")
30
- ? "+"
31
- : line.text.startsWith("-")
32
- ? "-"
33
- : " ";
34
- const label = line.text.replace(/^[+ -]/, "");
35
- const tree = /^[ \t│┃├└─]*/u.exec(label)![0];
36
- const description = label.slice(tree.length);
30
+ const { sign, tree, description } = callStackRow(line.text);
37
31
  const comment = description.indexOf("#");
38
32
  const linked = line.references.length > 0;
39
33
  const row = index + 1;
@@ -125,27 +119,30 @@ export function CallStackDiff(
125
119
  </Tooltip>
126
120
  );
127
121
  })}
128
- </div>
122
+ </figure>
129
123
  );
130
124
  }
131
125
 
132
126
  const rowSize = defineVars({
133
- height: { default: "28px", [LARGE_SCALE]: "40px" },
127
+ height: { default: "22px", [LARGE_SCALE]: "32px" },
134
128
  });
135
129
 
136
130
  const styles = {
137
- shell: style(
138
- text({ size: "sm", fontWeight: 500, color: "highContrast" }),
139
- spacing.padding({ y: 2 }),
140
- {
141
- backgroundColor: backgroundColor.app,
142
- overflowX: "hidden",
143
- maxWidth: "100%",
131
+ // Sized like Maui's code block, but in the proportional UI font.
132
+ shell: style(text({ size: "xs", fontWeight: 400, color: "highContrast" }), {
133
+ margin: 0,
134
+ paddingBlock: "8px",
135
+ overflowX: "hidden",
136
+ maxWidth: "100%",
137
+ // The shell's top padding goes below the title, between it and the rows.
138
+ "& > figcaption": {
139
+ marginTop: "-8px",
140
+ marginBottom: "8px",
144
141
  },
145
- ),
142
+ }),
146
143
  row: style(
147
- spacing.padding({ x: 3 }),
148
144
  {
145
+ paddingInline: "12px",
149
146
  display: "flex",
150
147
  alignItems: "center",
151
148
  gap: "12px",
@@ -174,7 +171,6 @@ const styles = {
174
171
  backgroundColor: colors.red[5],
175
172
  boxShadow: `inset 3px 0 ${colors.red[9]}`,
176
173
  },
177
- "&[data-note]": { cursor: "help" },
178
174
  // The sign fills the row gutter, so the dot follows the label instead.
179
175
  "&[data-note='start'] .stack-label::after": {
180
176
  content: '""',
@@ -204,7 +200,7 @@ const styles = {
204
200
  alignSelf: "center",
205
201
  flex: "1 1 auto",
206
202
  minWidth: 0,
207
- paddingBlock: "3px",
203
+ paddingBlock: "1px",
208
204
  overflow: "hidden",
209
205
  textOverflow: "ellipsis",
210
206
  whiteSpace: "nowrap",
@@ -1,7 +1,6 @@
1
1
  import { useId, useState } from "react";
2
2
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
3
3
  import {
4
- backgroundColor,
5
4
  borderColor,
6
5
  ChevronRight,
7
6
  colors,
@@ -245,7 +244,6 @@ const STAT_INSET = 12;
245
244
 
246
245
  const styles = {
247
246
  shell: style(text({ size: "sm", color: "highContrast" }), {
248
- backgroundColor: backgroundColor.app,
249
247
  maxWidth: "100%",
250
248
  }),
251
249
  // Same insets and gap as the file rows, so the branch icon lines up with the file icons
@@ -1,19 +1,38 @@
1
1
  import { useEffect, useState, type ReactNode } from "react";
2
2
  import { getSharedHighlighter } from "@pierre/diffs";
3
- import { codeBlockStyles, colors, Tooltip, useTheme } from "maui";
3
+ import {
4
+ Button,
5
+ Check,
6
+ codeBlockStyles,
7
+ colors,
8
+ Copy,
9
+ Tooltip,
10
+ useTheme,
11
+ } from "maui";
4
12
  import { style, useStyles } from "purse-styles";
5
13
  import { codeFontFamily } from "../codeFont.js";
6
14
  import { lineSymbolMatches, type CodeNote } from "../codeNotes.js";
7
15
  import { lineNoteDot, NoteText, NoteTokens, type Token } from "./notes.tsx";
16
+ import { fenceTitle, pierreShell } from "./pierre.ts";
8
17
 
9
- /** A pseudocode fence with highlighted lines and hover notes. */
18
+ /** A pseudocode fence with an optional title, highlighted lines, hover notes, and a copy button. */
10
19
  export function CodeBlock(props: {
11
20
  lang: string;
12
21
  source: string;
13
22
  highlights: number[];
14
23
  notes: CodeNote[];
24
+ title: string | undefined;
15
25
  }) {
16
26
  const className = useStyles(codeBlockStyles.root, styles.block);
27
+ const figureClassName = useStyles(pierreShell, styles.figure);
28
+ const titleClassName = useStyles(fenceTitle);
29
+ const copyClassName = useStyles(styles.copy);
30
+ const [copied, setCopied] = useState(false);
31
+ useEffect(() => {
32
+ if (!copied) return;
33
+ const timer = setTimeout(() => setCopied(false), 1500);
34
+ return () => clearTimeout(timer);
35
+ }, [copied]);
17
36
  const lines = useTokenLines(props.source, props.lang);
18
37
  const sourceLines = props.source.split("\n");
19
38
 
@@ -54,9 +73,34 @@ export function CodeBlock(props: {
54
73
  }
55
74
 
56
75
  return (
57
- <pre className={className} data-diffmap-kind="code">
58
- <code>{rows}</code>
59
- </pre>
76
+ <figure
77
+ className={figureClassName}
78
+ data-wrap={proseLangs.has(props.lang) || undefined}
79
+ >
80
+ {props.title !== undefined && (
81
+ <figcaption className={titleClassName}>{props.title}</figcaption>
82
+ )}
83
+ <pre className={className} data-diffmap-kind="code">
84
+ <code>{rows}</code>
85
+ </pre>
86
+ <Button
87
+ className={copyClassName}
88
+ variant="quiet"
89
+ data-copy=""
90
+ data-copied={copied || undefined}
91
+ size="sm"
92
+ aria-label={copied ? "Copied" : "Copy code"}
93
+ onPress={() => {
94
+ // oxlint-disable-next-line typescript/no-floating-promises -- Press handlers cannot await; the icon only changes once the write succeeds.
95
+ void navigator.clipboard.writeText(props.source).then(
96
+ () => setCopied(true),
97
+ (cause) => console.warn("diffmap copy failed", cause),
98
+ );
99
+ }}
100
+ >
101
+ {copied ? <Check size="sm" /> : <Copy size="sm" />}
102
+ </Button>
103
+ </figure>
60
104
  );
61
105
  }
62
106
 
@@ -95,11 +139,40 @@ function useTokenLines(source: string, lang: string): Token[][] {
95
139
  return source.split("\n").map((line) => [{ content: line }]);
96
140
  }
97
141
 
142
+ /** Prose-like fences wrap long lines instead of scrolling. */
143
+ const proseLangs = new Set(["text", "txt", "plaintext", "md", "markdown"]);
144
+
98
145
  const styles = {
146
+ // The figure is the card, so the block inside drops its own edge.
147
+ figure: style({
148
+ position: "relative",
149
+ margin: 0,
150
+ "& > pre": {
151
+ boxShadow: "none",
152
+ borderRadius: 0,
153
+ backgroundColor: "transparent",
154
+ },
155
+ // Room for the copy button, which sits in the title row.
156
+ "& > figcaption": { paddingRight: "40px" },
157
+ "&:hover > [data-copy], &:focus-within > [data-copy]": { opacity: 1 },
158
+ "&[data-wrap] > pre > code": { display: "block" },
159
+ "&[data-wrap] .line": { whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
160
+ }),
161
+ // Hidden until the block is hovered or focused, and while it confirms a copy.
162
+ // Touch screens have no hover, so the button always shows there.
163
+ copy: style({
164
+ position: "absolute",
165
+ top: "6px",
166
+ right: "6px",
167
+ opacity: 0,
168
+ "&[data-copied]": { opacity: 1 },
169
+ "@media (hover: none)": { opacity: 1 },
170
+ }),
99
171
  block: style({
100
172
  fontFamily: codeFontFamily,
101
173
  paddingInline: 0,
102
- "& > code": {
174
+ // `:is(pre)` outranks the prose rule that shrinks inline code to 0.875em.
175
+ "&:is(pre) > code": {
103
176
  display: "inline-block",
104
177
  verticalAlign: "top",
105
178
  minWidth: "100%",
@@ -117,7 +190,6 @@ const styles = {
117
190
  "& .line-note": {
118
191
  display: "block",
119
192
  position: "relative",
120
- cursor: "help",
121
193
  outline: "none",
122
194
  },
123
195
  "& .line-note::before": lineNoteDot,
@@ -87,6 +87,13 @@ function renderElement(
87
87
  </blockquote>
88
88
  );
89
89
  }
90
+ if (node.tag === "details") {
91
+ return (
92
+ <Details key={key} summary={node.attrs.summary ?? "Details"}>
93
+ {children}
94
+ </Details>
95
+ );
96
+ }
90
97
  if (node.tag === "li" && node.attrs.task === true) {
91
98
  return (
92
99
  <TaskListItem key={key} node={node}>
@@ -188,6 +195,18 @@ function InlineReference(
188
195
  );
189
196
  }
190
197
 
198
+ /** MDC `::details{summary="…"}`: content folded under a summary row. */
199
+ function Details(props: { summary: string; children: ReactNode }) {
200
+ const className = useStyles(detailsClass);
201
+ const summaryClassName = useStyles(detailsSummaryClass);
202
+ return (
203
+ <details className={className}>
204
+ <summary className={summaryClassName}>{props.summary}</summary>
205
+ <div className="diffmap-details-content">{props.children}</div>
206
+ </details>
207
+ );
208
+ }
209
+
191
210
  function TaskListItem(props: { node: ViewerElement; children: ReactNode }) {
192
211
  const [checked, setChecked] = useState(props.node.attrs.checked === true);
193
212
  const className = useStyles(taskListItemClass);
@@ -250,6 +269,21 @@ const inlineReferenceClass = style(
250
269
  focusRing(),
251
270
  );
252
271
 
272
+ const detailsSummaryClass = style(radius.sm, focusRing(), {
273
+ cursor: "pointer",
274
+ width: "fit-content",
275
+ fontWeight: 500,
276
+ });
277
+
278
+ const detailsClass = style({
279
+ "& > .diffmap-details-content": {
280
+ display: "flex",
281
+ flexDirection: "column",
282
+ gap: spacing.value(8),
283
+ marginTop: spacing.value(6),
284
+ },
285
+ });
286
+
253
287
  const taskListItemClass = style({
254
288
  display: "grid",
255
289
  gridTemplateColumns: "auto minmax(0, 1fr)",
@@ -19,6 +19,8 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
19
19
  <MermaidBlock
20
20
  source={fence.source}
21
21
  annotations={fence.annotations}
22
+ sides={fence.sides}
23
+ title={fence.title}
22
24
  selectedAnnotation={props.selectedAnnotation}
23
25
  onSelectAnnotation={props.onSelectAnnotation}
24
26
  />
@@ -51,6 +53,7 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
51
53
  lines={fence.lines}
52
54
  highlights={fence.highlights}
53
55
  notes={fence.notes}
56
+ title={fence.title}
54
57
  selectedAnnotation={props.selectedAnnotation}
55
58
  onSelectAnnotation={props.onSelectAnnotation}
56
59
  />
@@ -74,6 +77,7 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
74
77
  source={fence.source}
75
78
  highlights={fence.highlights}
76
79
  notes={fence.notes}
80
+ title={fence.title}
77
81
  />
78
82
  );
79
83
  }
@@ -1,4 +1,4 @@
1
- import { radius, shadow, spacing } from "maui";
1
+ import { backgroundColor, radius, shadow, spacing } from "maui";
2
2
  import { style, useStyles } from "purse-styles";
3
3
 
4
4
  export function HtmlBlock(props: { source: string }) {
@@ -15,5 +15,6 @@ export function HtmlBlock(props: { source: string }) {
15
15
  const styles = {
16
16
  shell: style(radius.md, shadow.subtle, spacing.padding({ all: 4 }), {
17
17
  minWidth: 0,
18
+ backgroundColor: backgroundColor.element,
18
19
  }),
19
20
  };
@@ -8,26 +8,36 @@ import {
8
8
  colors,
9
9
  fontFamily,
10
10
  spacing,
11
+ text,
11
12
  } from "maui";
12
13
  import { style, useStyles } from "purse-styles";
13
14
  import { mermaidSvg } from "../mermaid.js";
14
15
  import type { DiagramAnnotation } from "../diagramAnnotations.js";
15
16
  import type { SourceNavigation } from "../annotations.js";
17
+ import { markDiagramChanges } from "./diagramDiff.js";
16
18
  import { linkDiagram } from "./diagramLinks.js";
19
+ import { fenceTitle, pierreShell } from "./pierre.js";
17
20
 
18
21
  type MermaidProps = {
19
22
  source: string;
20
23
  annotations: DiagramAnnotation[];
24
+ sides: { today: string; proposed: string } | undefined;
25
+ title: string | undefined;
21
26
  } & SourceNavigation;
22
27
 
23
28
  export function MermaidBlock(props: MermaidProps) {
24
29
  const [fullscreen, setFullscreen] = useState(false);
25
- const frame = useStyles(styles.frame);
30
+ const frame = useStyles(pierreShell, styles.frame);
26
31
  const expand = useStyles(styles.expand);
27
32
  const dialog = useStyles(styles.dialog);
28
33
  const header = useStyles(styles.header);
34
+ const caption = useStyles(fenceTitle);
35
+ const dialogTitle = useStyles(styles.dialogTitle);
29
36
  return (
30
- <div className={frame}>
37
+ <figure className={frame}>
38
+ {props.title !== undefined && (
39
+ <figcaption className={caption}>{props.title}</figcaption>
40
+ )}
31
41
  <MermaidDiagram {...props} fullscreen={false} />
32
42
  <Button
33
43
  className={expand}
@@ -42,10 +52,11 @@ export function MermaidBlock(props: MermaidProps) {
42
52
  isOpen={fullscreen}
43
53
  onOpenChange={setFullscreen}
44
54
  size="lg"
45
- aria-label="Diagram"
55
+ aria-label={props.title ?? "Diagram"}
46
56
  className={dialog}
47
57
  >
48
58
  <div className={header}>
59
+ <span className={dialogTitle}>{props.title}</span>
49
60
  <Button
50
61
  size="sm"
51
62
  variant="quiet"
@@ -65,7 +76,7 @@ export function MermaidBlock(props: MermaidProps) {
65
76
  }}
66
77
  />
67
78
  </Dialog>
68
- </div>
79
+ </figure>
69
80
  );
70
81
  }
71
82
 
@@ -75,10 +86,10 @@ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
75
86
  styles.shell,
76
87
  props.fullscreen ? styles.fullscreenShell : styles.inlineShell,
77
88
  );
78
- const svg = useMemo(
79
- () =>
89
+ const svg = useMemo(() => {
90
+ const draw = (source: string) =>
80
91
  mermaidSvg({
81
- source: props.source,
92
+ source,
82
93
  bg: backgroundColor.app,
83
94
  fg: colors.gray[12],
84
95
  accent: colors.accent[9],
@@ -86,9 +97,15 @@ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
86
97
  surface: backgroundColor.element,
87
98
  border: colors.gray[6],
88
99
  font: fontFamily,
89
- }),
90
- [props.source],
91
- );
100
+ });
101
+ const all = draw(props.source);
102
+ if (all instanceof Error || props.sides === undefined) return all;
103
+ const today = draw(props.sides.today);
104
+ if (today instanceof Error) return today;
105
+ const proposed = draw(props.sides.proposed);
106
+ if (proposed instanceof Error) return proposed;
107
+ return markDiagramChanges(all, { today, proposed });
108
+ }, [props.source, props.sides]);
92
109
  const linked = useMemo(
93
110
  () => (svg instanceof Error ? svg : linkDiagram(svg, props.annotations)),
94
111
  [svg, props.annotations],
@@ -152,16 +169,23 @@ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
152
169
  }
153
170
 
154
171
  const styles = {
172
+ // A card like a code block: the title row, then the diagram, padded.
155
173
  frame: style({
156
174
  position: "relative",
157
175
  width: "100%",
158
- minWidth: 0,
176
+ margin: 0,
177
+ // Room for the full-screen button, which sits in the title row.
178
+ "& > figcaption": { paddingRight: "40px" },
179
+ "& > [data-diffmap-kind='mermaid']": { padding: spacing.value(6) },
159
180
  }),
160
181
  expand: style({
161
182
  position: "absolute",
162
- top: 0,
163
- insetInlineEnd: 0,
183
+ top: "6px",
184
+ right: "6px",
164
185
  }),
186
+ dialogTitle: style(
187
+ text({ size: "xs", fontWeight: 500, color: "lowContrast" }),
188
+ ),
165
189
  dialog: style({
166
190
  width: "calc(100vw - 32px)",
167
191
  height: "calc(100dvh - 32px)",
@@ -170,7 +194,8 @@ const styles = {
170
194
  }),
171
195
  header: style({
172
196
  display: "flex",
173
- justifyContent: "flex-end",
197
+ alignItems: "center",
198
+ justifyContent: "space-between",
174
199
  }),
175
200
  inlineShell: style({
176
201
  maxHeight: "60vh",
@@ -210,5 +235,21 @@ const styles = {
210
235
  width: "100%",
211
236
  height: "auto",
212
237
  },
238
+ ...changeStyles("-", colors.red),
239
+ ...changeStyles("+", colors.green),
240
+ ...changeStyles("~", colors.amber),
213
241
  }),
214
242
  };
243
+
244
+ /** Colors a `mermaid diff`'s marked nodes and edges; removed ones are dashed. */
245
+ function changeStyles(sign: string, scale: typeof colors.red) {
246
+ const marked = `[data-change='${sign}']`;
247
+ const dash = sign === "-" ? { strokeDasharray: "5 3" } : {};
248
+ return {
249
+ [`& [data-id]${marked} > :is(rect, polygon, circle, ellipse, path), & .edge-label${marked} > rect`]:
250
+ { fill: scale[2], stroke: scale[9], strokeWidth: 1.5, ...dash },
251
+ [`& ${marked} text`]: { fill: scale[11] },
252
+ [`& :is(polyline, line)${marked}, & ${marked} > :is(line, polyline, path)`]:
253
+ { stroke: scale[9], strokeWidth: 1.5, ...dash },
254
+ };
255
+ }