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