@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.
@@ -1,6 +1,8 @@
1
+ import { useId, useState } from "react";
2
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
1
3
  import {
2
- backgroundColor,
3
4
  borderColor,
5
+ ChevronRight,
4
6
  colors,
5
7
  Css,
6
8
  File,
@@ -8,12 +10,15 @@ import {
8
10
  FileText,
9
11
  focusRing,
10
12
  GitBranch,
13
+ motionDurationMs,
14
+ motionEasing,
11
15
  text,
12
16
  } from "maui";
13
17
  import { style, useStyles } from "purse-styles";
14
- import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
18
+ import type { SourceNavigation } from "../annotations.js";
15
19
  import {
16
20
  isCommitRev,
21
+ orderForReview,
17
22
  shortRev,
18
23
  WORKING,
19
24
  type ChangedFile,
@@ -24,19 +29,32 @@ import { codeFontFamily } from "../codeFont.js";
24
29
  import { useChanges } from "./changesSource.ts";
25
30
  import { pierreShell } from "./pierre.js";
26
31
 
27
- /** A phase's file summary. Review opens every file; a row focuses one. */
28
- export function ChangesCard(props: { rev: Rev } & SourceNavigation) {
32
+ /**
33
+ * A phase's file summary. Review opens every file; a row focuses one. Files
34
+ * the fence lists come first, in its order; the rest fold under a dimmed
35
+ * "N other files" section.
36
+ */
37
+ export function ChangesCard(
38
+ props: { rev: Rev; review: string[] } & SourceNavigation,
39
+ ) {
29
40
  const changes = useChanges(props.rev);
41
+ const [showOther, setShowOther] = useState(false);
42
+ const otherId = useId();
43
+ const reducedMotion = useReducedMotion();
30
44
  const shell = useStyles(pierreShell, styles.shell);
31
45
  const header = useStyles(styles.header);
32
- const row = useStyles(styles.row);
33
46
  const review = useStyles(styles.review);
34
47
  const muted = useStyles(styles.muted);
35
48
  const range = useStyles(styles.range);
36
- const open = (annotation: SourceAnnotation) =>
37
- props.onSelectAnnotation(annotation);
49
+ const toggle = useStyles(styles.row, styles.toggle);
38
50
  const files =
39
51
  changes === undefined || changes instanceof Error ? [] : changes.files;
52
+ const { reviewed, other } = orderForReview(files, props.review);
53
+ const rowProps = {
54
+ rev: props.rev,
55
+ selectedAnnotation: props.selectedAnnotation,
56
+ onSelectAnnotation: props.onSelectAnnotation,
57
+ };
40
58
  return (
41
59
  <div className={shell} data-diffmap-kind="changes">
42
60
  <div className={header}>
@@ -55,7 +73,7 @@ export function ChangesCard(props: { rev: Rev } & SourceNavigation) {
55
73
  className={review}
56
74
  aria-controls="source-diff-panel"
57
75
  onClick={() =>
58
- open({
76
+ props.onSelectAnnotation({
59
77
  text: "Review",
60
78
  references: [{ kind: "changes", rev: props.rev }],
61
79
  })
@@ -65,45 +83,95 @@ export function ChangesCard(props: { rev: Rev } & SourceNavigation) {
65
83
  </button>
66
84
  )}
67
85
  </div>
68
- {files.map((file) => {
69
- const annotation: SourceAnnotation = {
70
- text: file.path,
71
- references: [
72
- props.rev === WORKING
73
- ? { kind: "file", path: file.path }
74
- : { kind: "file", rev: props.rev, path: file.path },
75
- ],
76
- };
77
- return (
86
+ {reviewed.map((file) => (
87
+ <FileRow key={file.path} file={file} {...rowProps} />
88
+ ))}
89
+ {other.length > 0 && (
90
+ <>
78
91
  <button
79
- key={file.path}
80
92
  type="button"
81
- className={row}
82
- title={file.path}
83
- aria-controls="source-diff-panel"
84
- aria-pressed={
85
- props.selectedAnnotation?.text === file.path &&
86
- props.selectedAnnotation.references[0]?.kind === "file" &&
87
- props.selectedAnnotation.references[0].rev ===
88
- annotation.references[0]!.rev
89
- }
90
- onClick={() => open(annotation)}
93
+ className={toggle}
94
+ aria-expanded={showOther}
95
+ aria-controls={otherId}
96
+ onClick={() => setShowOther((shown) => !shown)}
91
97
  >
92
- <FileIcon path={file.path} />
98
+ <ChevronRight size="sm" />
93
99
  <span className="name">
94
- {file.path.split("/").pop()}
95
- {file.status === "deleted" && (
96
- <span className={muted}> deleted</span>
97
- )}
100
+ {other.length} other {other.length === 1 ? "file" : "files"}
98
101
  </span>
99
- <DiffStat file={file} />
102
+ <DiffStat
103
+ additions={other.reduce((sum, file) => sum + file.additions, 0)}
104
+ deletions={other.reduce((sum, file) => sum + file.deletions, 0)}
105
+ binary={false}
106
+ />
100
107
  </button>
101
- );
102
- })}
108
+ <AnimatePresence initial={false}>
109
+ {showOther && (
110
+ <motion.div
111
+ key="other"
112
+ id={otherId}
113
+ role="group"
114
+ aria-label="Other files"
115
+ style={{ overflow: "hidden" }}
116
+ initial={{ height: 0, opacity: 0 }}
117
+ animate={{ height: "auto", opacity: 1 }}
118
+ exit={{ height: 0, opacity: 0 }}
119
+ transition={
120
+ reducedMotion === true
121
+ ? { duration: 0 }
122
+ : { type: "spring", visualDuration: 0.22, bounce: 0 }
123
+ }
124
+ >
125
+ {other.map((file) => (
126
+ <FileRow key={file.path} file={file} dimmed {...rowProps} />
127
+ ))}
128
+ </motion.div>
129
+ )}
130
+ </AnimatePresence>
131
+ </>
132
+ )}
103
133
  </div>
104
134
  );
105
135
  }
106
136
 
137
+ /** One changed file; selecting it opens that file in the Diff panel. */
138
+ function FileRow(
139
+ props: { rev: Rev; file: ChangedFile; dimmed?: boolean } & SourceNavigation,
140
+ ) {
141
+ const row = useStyles(styles.row);
142
+ const muted = useStyles(styles.muted);
143
+ const { file, rev } = props;
144
+ const reference =
145
+ rev === WORKING
146
+ ? { kind: "file" as const, path: file.path }
147
+ : { kind: "file" as const, rev, path: file.path };
148
+ const selected = props.selectedAnnotation?.references[0];
149
+ return (
150
+ <button
151
+ type="button"
152
+ className={row}
153
+ title={file.path}
154
+ data-dimmed={props.dimmed === true}
155
+ aria-controls="source-diff-panel"
156
+ aria-pressed={
157
+ props.selectedAnnotation?.text === file.path &&
158
+ selected?.kind === "file" &&
159
+ selected.rev === reference.rev
160
+ }
161
+ onClick={() =>
162
+ props.onSelectAnnotation({ text: file.path, references: [reference] })
163
+ }
164
+ >
165
+ <FileIcon path={file.path} />
166
+ <span className="name">
167
+ {file.path.split("/").pop()}
168
+ {file.status === "deleted" && <span className={muted}> deleted</span>}
169
+ </span>
170
+ <DiffStat {...file} />
171
+ </button>
172
+ );
173
+ }
174
+
107
175
  /** "9 files changed +214 −119" for a whole rev; the card and the Diff panel header share it. */
108
176
  export function ChangesSummary(props: {
109
177
  changes: Changes;
@@ -140,15 +208,15 @@ function revLabel(rev: Rev, changes: Changes | Error | undefined) {
140
208
  return shortRev(rev).replace("..", "…");
141
209
  }
142
210
 
143
- function DiffStat(props: { file: ChangedFile }) {
144
- if (props.file.binary) return <span className="stat">binary</span>;
211
+ function DiffStat(
212
+ props: Pick<ChangedFile, "additions" | "deletions" | "binary">,
213
+ ) {
214
+ if (props.binary) return <span className="stat">binary</span>;
145
215
  return (
146
216
  <span className="stat">
147
- {props.file.additions > 0 && (
148
- <span className="added">+{props.file.additions}</span>
149
- )}
150
- {props.file.deletions > 0 && (
151
- <span className="removed">−{props.file.deletions}</span>
217
+ {props.additions > 0 && <span className="added">+{props.additions}</span>}
218
+ {props.deletions > 0 && (
219
+ <span className="removed">−{props.deletions}</span>
152
220
  )}
153
221
  </span>
154
222
  );
@@ -176,7 +244,6 @@ const STAT_INSET = 12;
176
244
 
177
245
  const styles = {
178
246
  shell: style(text({ size: "sm", color: "highContrast" }), {
179
- backgroundColor: backgroundColor.app,
180
247
  maxWidth: "100%",
181
248
  }),
182
249
  // Same insets and gap as the file rows, so the branch icon lines up with the file icons
@@ -260,8 +327,29 @@ const styles = {
260
327
  "&[aria-pressed='true'], &[aria-pressed='true']:hover": {
261
328
  backgroundColor: colors.blue[3],
262
329
  },
330
+ // Files outside the review list recede until you point at or pick one.
331
+ "&[data-dimmed='true'] > *": {
332
+ opacity: 0.5,
333
+ transition: `opacity ${String(motionDurationMs)}ms ${motionEasing}`,
334
+ },
335
+ "&[data-dimmed='true']:is(:hover, :focus-visible, [aria-pressed='true']) > *":
336
+ { opacity: 1 },
263
337
  },
264
338
  focusRing(),
265
339
  ),
340
+ // A quiet footer row: the chevron sits in the icon column, so the count lines up with file names.
341
+ toggle: style({
342
+ color: colors.gray[11],
343
+ borderTop: `1px solid ${borderColor.border}`,
344
+ "& > svg": { transition: "transform 150ms ease-out" },
345
+ "&[aria-expanded='true'] > svg": { transform: "rotate(90deg)" },
346
+ // The total stat steps aside once the files show their own.
347
+ "& > .stat": {
348
+ opacity: 0.7,
349
+ transition: `opacity ${String(motionDurationMs)}ms ${motionEasing}`,
350
+ },
351
+ "&[aria-expanded='true'] > .stat": { opacity: 0 },
352
+ "&:hover": { color: colors.gray[12] },
353
+ }),
266
354
  muted: style({ color: colors.gray[11] }),
267
355
  };
@@ -0,0 +1,204 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { getSharedHighlighter } from "@pierre/diffs";
3
+ import {
4
+ Button,
5
+ Check,
6
+ codeBlockStyles,
7
+ colors,
8
+ Copy,
9
+ Tooltip,
10
+ useTheme,
11
+ } from "maui";
12
+ import { style, useStyles } from "purse-styles";
13
+ import { codeFontFamily } from "../codeFont.js";
14
+ import { lineSymbolMatches, type CodeNote } from "../codeNotes.js";
15
+ import { lineNoteDot, NoteText, NoteTokens, type Token } from "./notes.tsx";
16
+ import { fenceTitle, pierreShell } from "./pierre.ts";
17
+
18
+ /** A pseudocode fence with an optional title, highlighted lines, hover notes, and a copy button. */
19
+ export function CodeBlock(props: {
20
+ lang: string;
21
+ source: string;
22
+ highlights: number[];
23
+ notes: CodeNote[];
24
+ title: string | undefined;
25
+ }) {
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]);
36
+ const lines = useTokenLines(props.source, props.lang);
37
+ const sourceLines = props.source.split("\n");
38
+
39
+ const renderLine = (index: number) => (
40
+ <span
41
+ key={index}
42
+ className="line"
43
+ data-highlighted={props.highlights.includes(index + 1) || undefined}
44
+ >
45
+ <NoteTokens
46
+ tokens={lines[index] ?? []}
47
+ matches={lineSymbolMatches(sourceLines[index]!, props.notes)}
48
+ />
49
+ </span>
50
+ );
51
+
52
+ const rows: ReactNode[] = [];
53
+ for (let index = 0; index < sourceLines.length;) {
54
+ const note = props.notes.find(
55
+ (candidate) =>
56
+ candidate.target.kind === "lines" &&
57
+ candidate.target.start === index + 1,
58
+ );
59
+ if (note === undefined || note.target.kind !== "lines") {
60
+ rows.push(renderLine(index));
61
+ index++;
62
+ continue;
63
+ }
64
+ const range: ReactNode[] = [];
65
+ for (; index < note.target.end; index++) range.push(renderLine(index));
66
+ rows.push(
67
+ <Tooltip key={`note-${index}`} content={<NoteText text={note.text} />}>
68
+ <span className="line-note" tabIndex={0}>
69
+ {range}
70
+ </span>
71
+ </Tooltip>,
72
+ );
73
+ }
74
+
75
+ return (
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>
104
+ );
105
+ }
106
+
107
+ /** Tokens per line from Pierre's highlighter; plain text until it loads or for unknown languages. */
108
+ function useTokenLines(source: string, lang: string): Token[][] {
109
+ const { resolvedTheme } = useTheme();
110
+ const [highlighted, setHighlighted] = useState<{
111
+ key: string;
112
+ lines: Token[][];
113
+ }>();
114
+ const key = `${resolvedTheme}\n${lang}\n${source}`;
115
+ useEffect(() => {
116
+ let cancelled = false;
117
+ const theme = `pierre-${resolvedTheme}`;
118
+ const tokenize = async (language: string) => {
119
+ const highlighter = await getSharedHighlighter({
120
+ themes: [theme],
121
+ langs: [language],
122
+ });
123
+ return highlighter.codeToTokens(source, { lang: language, theme }).tokens;
124
+ };
125
+ // oxlint-disable-next-line typescript/no-floating-promises -- React effects cannot await; this request owns the token update.
126
+ void tokenize(lang)
127
+ .catch(() => tokenize("text"))
128
+ .then((lines) => {
129
+ if (!cancelled) setHighlighted({ key, lines });
130
+ })
131
+ .catch((cause) => {
132
+ console.warn("diffmap code highlight failed", cause);
133
+ });
134
+ return () => {
135
+ cancelled = true;
136
+ };
137
+ }, [key, lang, resolvedTheme, source]);
138
+ if (highlighted?.key === key) return highlighted.lines;
139
+ return source.split("\n").map((line) => [{ content: line }]);
140
+ }
141
+
142
+ /** Prose-like fences wrap long lines instead of scrolling. */
143
+ const proseLangs = new Set(["text", "txt", "plaintext", "md", "markdown"]);
144
+
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
+ }),
171
+ block: style({
172
+ fontFamily: codeFontFamily,
173
+ paddingInline: 0,
174
+ // `:is(pre)` outranks the prose rule that shrinks inline code to 0.875em.
175
+ "&:is(pre) > code": {
176
+ display: "inline-block",
177
+ verticalAlign: "top",
178
+ minWidth: "100%",
179
+ font: "inherit",
180
+ },
181
+ "& .line": {
182
+ display: "block",
183
+ minHeight: "1lh",
184
+ paddingInline: "12px",
185
+ },
186
+ "& .line[data-highlighted]": {
187
+ backgroundColor: colors.blue[3],
188
+ boxShadow: `inset 3px 0 ${colors.blue[9]}`,
189
+ },
190
+ "& .line-note": {
191
+ display: "block",
192
+ position: "relative",
193
+ cursor: "help",
194
+ outline: "none",
195
+ },
196
+ "& .line-note::before": lineNoteDot,
197
+ "& .line-note:hover > .line:not([data-highlighted])": {
198
+ backgroundColor: colors.gray[3],
199
+ },
200
+ "& .line-note:focus-visible": {
201
+ boxShadow: `inset 0 0 0 2px ${colors.blue[8]}`,
202
+ },
203
+ }),
204
+ };
@@ -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)",
@@ -1,13 +1,14 @@
1
- import { CodeBlock } from "maui";
2
1
  import type { Fence as FenceModel } from "../parseFence.js";
3
2
  import type { SourceNavigation } from "../annotations.js";
4
3
  import { CallStackDiff } from "./CallStackDiff.tsx";
5
4
  import { ChangesCard } from "./ChangesCard.tsx";
5
+ import { CodeBlock } from "./CodeBlock.tsx";
6
6
  import { CodeDiff } from "./CodeDiff.tsx";
7
7
  import { FileExcerpt } from "./FileExcerpt.tsx";
8
8
  import { HtmlBlock } from "./HtmlBlock.tsx";
9
9
  import { HtmlPlaceholder } from "./HtmlPlaceholder.tsx";
10
10
  import { MermaidBlock } from "./MermaidBlock.tsx";
11
+ import { ServiceDiagramBlock } from "./ServiceDiagramBlock.tsx";
11
12
  import { useViewerMode } from "./viewerMode.ts";
12
13
 
13
14
  export function Fence(props: { fence: FenceModel } & SourceNavigation) {
@@ -18,6 +19,17 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
18
19
  <MermaidBlock
19
20
  source={fence.source}
20
21
  annotations={fence.annotations}
22
+ sides={fence.sides}
23
+ title={fence.title}
24
+ selectedAnnotation={props.selectedAnnotation}
25
+ onSelectAnnotation={props.onSelectAnnotation}
26
+ />
27
+ );
28
+ if (fence.kind === "services")
29
+ return (
30
+ <ServiceDiagramBlock
31
+ source={fence.source}
32
+ parsed={fence.parsed}
21
33
  selectedAnnotation={props.selectedAnnotation}
22
34
  onSelectAnnotation={props.onSelectAnnotation}
23
35
  />
@@ -30,6 +42,7 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
30
42
  return (
31
43
  <ChangesCard
32
44
  rev={fence.rev}
45
+ review={fence.review}
33
46
  selectedAnnotation={props.selectedAnnotation}
34
47
  onSelectAnnotation={props.onSelectAnnotation}
35
48
  />
@@ -38,6 +51,9 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
38
51
  return (
39
52
  <CallStackDiff
40
53
  lines={fence.lines}
54
+ highlights={fence.highlights}
55
+ notes={fence.notes}
56
+ title={fence.title}
41
57
  selectedAnnotation={props.selectedAnnotation}
42
58
  onSelectAnnotation={props.onSelectAnnotation}
43
59
  />
@@ -55,5 +71,13 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
55
71
  />
56
72
  );
57
73
  }
58
- return <CodeBlock lang={fence.lang}>{fence.source}</CodeBlock>;
74
+ return (
75
+ <CodeBlock
76
+ lang={fence.lang}
77
+ source={fence.source}
78
+ highlights={fence.highlights}
79
+ notes={fence.notes}
80
+ title={fence.title}
81
+ />
82
+ );
59
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
  };