@tanishqkancharla/diffmap 0.1.5 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +64 -57
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +235 -0
  6. package/src/cli.ts +1 -0
  7. package/src/errors.ts +5 -0
  8. package/src/gist/GistApp.tsx +38 -17
  9. package/src/gist/fetchGist.ts +47 -0
  10. package/src/git.test.ts +402 -0
  11. package/src/git.ts +498 -0
  12. package/src/github/GitHubApp.tsx +39 -22
  13. package/src/github/fetchRepo.test.ts +59 -1
  14. package/src/github/fetchRepo.ts +99 -43
  15. package/src/github/patch.test.ts +18 -9
  16. package/src/github/patch.ts +30 -19
  17. package/src/host/HostHome.tsx +20 -7
  18. package/src/host/files.ts +5 -1
  19. package/src/host/home.md +17 -55
  20. package/src/host/home.test.ts +4 -3
  21. package/src/index.ts +1 -0
  22. package/src/parseFence.ts +6 -6
  23. package/src/parseViewer.test.ts +140 -0
  24. package/src/parseViewer.ts +104 -78
  25. package/src/serve.ts +34 -1
  26. package/src/share.ts +62 -1
  27. package/src/viewer/CallStackDiff.tsx +15 -3
  28. package/src/viewer/ChangesCard.tsx +267 -0
  29. package/src/viewer/ComarkView.tsx +83 -15
  30. package/src/viewer/Fence.tsx +9 -1
  31. package/src/viewer/MermaidBlock.tsx +99 -11
  32. package/src/viewer/RangePicker.tsx +526 -0
  33. package/src/viewer/SourceDiffPanel.tsx +254 -116
  34. package/src/viewer/TableOfContents.tsx +177 -93
  35. package/src/viewer/ViewerApp.tsx +34 -114
  36. package/src/viewer/changesSource.ts +91 -0
  37. package/src/viewer/sourceHeader.test.ts +2 -87
  38. package/src/viewer/sourceHeader.ts +4 -63
  39. package/src/viewer/sourceNavigation.ts +0 -43
  40. package/vite.config.ts +1 -0
  41. package/vite.gist.config.ts +57 -0
  42. package/src/viewer/TocButton.tsx +0 -27
@@ -0,0 +1,267 @@
1
+ import {
2
+ backgroundColor,
3
+ borderColor,
4
+ colors,
5
+ Css,
6
+ File,
7
+ FileCode,
8
+ FileText,
9
+ focusRing,
10
+ GitBranch,
11
+ text,
12
+ } from "maui";
13
+ import { style, useStyles } from "purse-styles";
14
+ import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
15
+ import {
16
+ isCommitRev,
17
+ shortRev,
18
+ WORKING,
19
+ type ChangedFile,
20
+ type Changes,
21
+ type Rev,
22
+ } from "../changes.js";
23
+ import { codeFontFamily } from "../codeFont.js";
24
+ import { useChanges } from "./changesSource.ts";
25
+ import { pierreShell } from "./pierre.js";
26
+
27
+ /** A phase's file summary. Review opens every file; a row focuses one. */
28
+ export function ChangesCard(props: { rev: Rev } & SourceNavigation) {
29
+ const changes = useChanges(props.rev);
30
+ const shell = useStyles(pierreShell, styles.shell);
31
+ const header = useStyles(styles.header);
32
+ const row = useStyles(styles.row);
33
+ const review = useStyles(styles.review);
34
+ const muted = useStyles(styles.muted);
35
+ const range = useStyles(styles.range);
36
+ const open = (annotation: SourceAnnotation) =>
37
+ props.onSelectAnnotation(annotation);
38
+ const files =
39
+ changes === undefined || changes instanceof Error ? [] : changes.files;
40
+ return (
41
+ <div className={shell} data-diffmap-kind="changes">
42
+ <div className={header}>
43
+ <GitBranch size="sm" />
44
+ <span className={range}>{revLabel(props.rev, changes)}</span>
45
+ {changes === undefined || changes instanceof Error ? (
46
+ <span className={muted}>
47
+ {changes === undefined ? "Loading changes…" : changes.message}
48
+ </span>
49
+ ) : (
50
+ <ChangesSummary changes={changes} />
51
+ )}
52
+ {files.length > 0 && (
53
+ <button
54
+ type="button"
55
+ className={review}
56
+ aria-controls="source-diff-panel"
57
+ onClick={() =>
58
+ open({
59
+ text: "Review",
60
+ references: [{ kind: "changes", rev: props.rev }],
61
+ })
62
+ }
63
+ >
64
+ Review
65
+ </button>
66
+ )}
67
+ </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 (
78
+ <button
79
+ key={file.path}
80
+ 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)}
91
+ >
92
+ <FileIcon path={file.path} />
93
+ <span className="name">
94
+ {file.path.split("/").pop()}
95
+ {file.status === "deleted" && (
96
+ <span className={muted}> deleted</span>
97
+ )}
98
+ </span>
99
+ <DiffStat file={file} />
100
+ </button>
101
+ );
102
+ })}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ /** "9 files changed +214 −119" for a whole rev; the card and the Diff panel header share it. */
108
+ export function ChangesSummary(props: {
109
+ changes: Changes;
110
+ className?: string;
111
+ }) {
112
+ const summary = useStyles(styles.summary);
113
+ const { files, truncated } = props.changes;
114
+ const additions = files.reduce((sum, file) => sum + file.additions, 0);
115
+ const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
116
+ const count = files.length;
117
+ return (
118
+ <span className={`${summary} ${props.className ?? ""}`}>
119
+ <span
120
+ title={
121
+ truncated === true
122
+ ? "GitHub listed only these files. Open the commit on GitHub for the rest."
123
+ : undefined
124
+ }
125
+ >
126
+ {count}
127
+ {truncated === true && "+"} {count === 1 ? "file" : "files"} changed
128
+ </span>
129
+ {additions > 0 && <span className="added">+{additions}</span>}
130
+ {deletions > 0 && <span className="removed">−{deletions}</span>}
131
+ </span>
132
+ );
133
+ }
134
+
135
+ /** Labels the rev as the range picker does: a commit by its title, a range as `<from>…<to>`. */
136
+ function revLabel(rev: Rev, changes: Changes | Error | undefined) {
137
+ const single = isCommitRev(rev) && !rev.includes("..");
138
+ if (single && changes !== undefined && !(changes instanceof Error))
139
+ return changes.title;
140
+ return shortRev(rev).replace("..", "…");
141
+ }
142
+
143
+ function DiffStat(props: { file: ChangedFile }) {
144
+ if (props.file.binary) return <span className="stat">binary</span>;
145
+ return (
146
+ <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>
152
+ )}
153
+ </span>
154
+ );
155
+ }
156
+
157
+ function FileIcon(props: { path: string }) {
158
+ const extension = props.path.split(".").pop()?.toLowerCase() ?? "";
159
+ if (/^(css|scss|less)$/.test(extension)) return <Css size="sm" />;
160
+ if (/^(md|mdx|txt|rst)$/.test(extension)) return <FileText size="sm" />;
161
+ if (
162
+ /^(c|cc|cpp|cs|go|h|java|jsx?|kt|mjs|cjs|py|rb|rs|sh|swift|tsx?|vue|svelte|json|ya?ml|toml|html)$/.test(
163
+ extension,
164
+ )
165
+ )
166
+ return <FileCode size="sm" />;
167
+ return <File size="sm" />;
168
+ }
169
+
170
+ /** Left inset of the file rows' icons. */
171
+ const ROW_INSET = 6;
172
+ /** Review's box inset from the card's top and right edges: (36px header − 24px button) / 2. */
173
+ const EDGE = 6;
174
+ /** Right inset of the stats, which Review's text also ends at. */
175
+ const STAT_INSET = 12;
176
+
177
+ const styles = {
178
+ shell: style(text({ size: "sm", color: "highContrast" }), {
179
+ backgroundColor: backgroundColor.app,
180
+ maxWidth: "100%",
181
+ }),
182
+ // Same insets and gap as the file rows, so the branch icon lines up with the file icons
183
+ // and the range with the names. Review's box sits EDGE from the top and right; its
184
+ // text lines up with the stats.
185
+ header: style({
186
+ display: "flex",
187
+ alignItems: "center",
188
+ gap: "10px",
189
+ minHeight: "36px",
190
+ paddingLeft: `${String(ROW_INSET)}px`,
191
+ paddingRight: `${String(EDGE)}px`,
192
+ "& > svg": { flexShrink: 0, color: colors.gray[11] },
193
+ // Divides the summary from the file rows; a card with no files has no divider.
194
+ "&:not(:last-child)": { borderBottom: `1px solid ${borderColor.border}` },
195
+ }),
196
+ range: style(text({ size: "sm", fontWeight: 500, color: "highContrast" }), {
197
+ flex: "1 1 auto",
198
+ minWidth: 0,
199
+ overflow: "hidden",
200
+ textOverflow: "ellipsis",
201
+ whiteSpace: "nowrap",
202
+ }),
203
+ summary: style(text({ size: "sm", color: "lowContrast" }), {
204
+ display: "flex",
205
+ gap: "8px",
206
+ flexShrink: 0,
207
+ whiteSpace: "nowrap",
208
+ fontVariantNumeric: "tabular-nums",
209
+ "& .added, & .removed": { fontFamily: codeFontFamily },
210
+ "& .added": { color: colors.green[11] },
211
+ "& .removed": { color: colors.red[11] },
212
+ }),
213
+ review: style(
214
+ text({ size: "sm", fontWeight: 400, color: "highContrast" }),
215
+ {
216
+ height: "24px",
217
+ padding: `0 ${String(STAT_INSET - EDGE)}px`,
218
+ border: 0,
219
+ borderRadius: "4px",
220
+ background: "transparent",
221
+ cursor: "pointer",
222
+ "&:hover": { backgroundColor: colors.gray[3] },
223
+ },
224
+ focusRing(),
225
+ ),
226
+ row: style(
227
+ {
228
+ paddingLeft: `${String(ROW_INSET)}px`,
229
+ paddingRight: `${String(STAT_INSET)}px`,
230
+ display: "flex",
231
+ alignItems: "center",
232
+ gap: "10px",
233
+ width: "100%",
234
+ minHeight: "32px",
235
+ font: "inherit",
236
+ textAlign: "left",
237
+ color: "inherit",
238
+ background: "transparent",
239
+ border: 0,
240
+ cursor: "pointer",
241
+ "& > svg": { flexShrink: 0, color: colors.gray[11] },
242
+ "& .name": {
243
+ flex: "1 1 auto",
244
+ minWidth: 0,
245
+ overflow: "hidden",
246
+ textOverflow: "ellipsis",
247
+ whiteSpace: "nowrap",
248
+ },
249
+ "& .stat": {
250
+ display: "flex",
251
+ gap: "8px",
252
+ flexShrink: 0,
253
+ fontFamily: codeFontFamily,
254
+ fontVariantNumeric: "tabular-nums",
255
+ color: colors.gray[11],
256
+ },
257
+ "& .added": { color: colors.green[11] },
258
+ "& .removed": { color: colors.red[11] },
259
+ "&:hover": { backgroundColor: colors.gray[3] },
260
+ "&[aria-pressed='true'], &[aria-pressed='true']:hover": {
261
+ backgroundColor: colors.blue[3],
262
+ },
263
+ },
264
+ focusRing(),
265
+ ),
266
+ muted: style({ color: colors.gray[11] }),
267
+ };
@@ -1,8 +1,9 @@
1
1
  import { createElement, type ReactNode, useState } from "react";
2
2
  import {
3
3
  Checkbox,
4
+ Code,
4
5
  colors,
5
- monospace,
6
+ focusRing,
6
7
  radius,
7
8
  spacing,
8
9
  Table,
@@ -19,7 +20,7 @@ import type {
19
20
  ViewerElement,
20
21
  ViewerNode,
21
22
  } from "../parseViewer.js";
22
- import type { SourceNavigation } from "../annotations.js";
23
+ import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
23
24
  import { Fence } from "./Fence.tsx";
24
25
  import { HtmlPlaceholder } from "./HtmlPlaceholder.tsx";
25
26
  import { useViewerMode } from "./viewerMode.ts";
@@ -60,6 +61,11 @@ function renderNode(
60
61
  if (node.type === "view") {
61
62
  return <Fence key={key} fence={node.fence} {...navigation} />;
62
63
  }
64
+ if (node.type === "reference") {
65
+ return (
66
+ <InlineReference key={key} annotation={node.annotation} {...navigation} />
67
+ );
68
+ }
63
69
  return renderElement(node, key, navigation, trustedHtml);
64
70
  }
65
71
 
@@ -89,7 +95,15 @@ function renderElement(
89
95
  );
90
96
  }
91
97
  if (node.tag === "code") {
92
- return <InlineChip key={key}>{children}</InlineChip>;
98
+ return (
99
+ <Code
100
+ key={key}
101
+ {...domAttrs(node)}
102
+ style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
103
+ >
104
+ {children}
105
+ </Code>
106
+ );
93
107
  }
94
108
  if (voidTags.has(node.tag)) {
95
109
  return createElement(node.tag, { key, ...domAttrs(node) });
@@ -156,12 +170,33 @@ function renderTable(
156
170
  );
157
171
  }
158
172
 
173
+ function InlineReference(
174
+ props: { annotation: SourceAnnotation } & SourceNavigation,
175
+ ) {
176
+ const className = useStyles(inlineReferenceClass);
177
+ return (
178
+ <button
179
+ type="button"
180
+ className={className}
181
+ aria-pressed={props.selectedAnnotation === props.annotation}
182
+ aria-controls="source-diff-panel"
183
+ title={props.annotation.text}
184
+ onClick={() => props.onSelectAnnotation(props.annotation)}
185
+ >
186
+ <Code>{props.annotation.text}</Code>
187
+ </button>
188
+ );
189
+ }
190
+
159
191
  function TaskListItem(props: { node: ViewerElement; children: ReactNode }) {
160
192
  const [checked, setChecked] = useState(props.node.attrs.checked === true);
193
+ const className = useStyles(taskListItemClass);
161
194
  return (
162
195
  <li
163
196
  id={props.node.attrs.id}
164
- className={props.node.attrs.className}
197
+ className={[className, props.node.attrs.className]
198
+ .filter(Boolean)
199
+ .join(" ")}
165
200
  data-task=""
166
201
  >
167
202
  <span className="diffmap-task-checkbox">
@@ -171,13 +206,14 @@ function TaskListItem(props: { node: ViewerElement; children: ReactNode }) {
171
206
  setChecked={setChecked}
172
207
  />
173
208
  </span>
174
- {props.children}
209
+ <div className="diffmap-task-content">{props.children}</div>
175
210
  </li>
176
211
  );
177
212
  }
178
213
 
179
214
  function nodeText(node: ViewerNode): string {
180
215
  if (node.type === "text") return node.value;
216
+ if (node.type === "reference") return node.annotation.text;
181
217
  if (node.type !== "element") return "";
182
218
  return node.children.map(nodeText).join("");
183
219
  }
@@ -194,16 +230,48 @@ function domAttrs(node: ViewerElement) {
194
230
  };
195
231
  }
196
232
 
197
- function InlineChip(props: { children?: ReactNode }) {
198
- const className = useStyles(inlineCodeClass);
199
- return <code className={className}>{props.children}</code>;
200
- }
201
-
202
- const inlineCodeClass = style(
203
- monospace,
204
- radius.md,
205
- spacing.padding({ x: 2, y: 1 }),
233
+ const inlineReferenceClass = style(
234
+ radius.sm,
206
235
  {
207
- backgroundColor: colors.gray[4],
236
+ display: "inline",
237
+ padding: 0,
238
+ border: 0,
239
+ font: "inherit",
240
+ color: "inherit",
241
+ background: "transparent",
242
+ cursor: "pointer",
243
+ "& > code": { color: colors.blue[11] },
244
+ "&:hover > code": { backgroundColor: colors.blue[3] },
245
+ "&[aria-pressed='true'] > code": {
246
+ backgroundColor: colors.blue[4],
247
+ color: colors.blue[12],
248
+ },
208
249
  },
250
+ focusRing(),
209
251
  );
252
+
253
+ const taskListItemClass = style({
254
+ display: "grid",
255
+ gridTemplateColumns: "auto minmax(0, 1fr)",
256
+ alignItems: "start",
257
+ columnGap: spacing.value(3),
258
+ "& > .diffmap-task-checkbox": {
259
+ display: "flex",
260
+ alignItems: "center",
261
+ minHeight: "1lh",
262
+ },
263
+ "& > .diffmap-task-content": {
264
+ minWidth: 0,
265
+ },
266
+ "& .diffmap-task-checkbox label > span:last-child": {
267
+ position: "absolute",
268
+ width: "1px",
269
+ height: "1px",
270
+ padding: 0,
271
+ margin: "-1px",
272
+ overflow: "hidden",
273
+ clip: "rect(0, 0, 0, 0)",
274
+ whiteSpace: "nowrap",
275
+ border: 0,
276
+ },
277
+ });
@@ -2,6 +2,7 @@ import { CodeBlock } from "maui";
2
2
  import type { Fence as FenceModel } from "../parseFence.js";
3
3
  import type { SourceNavigation } from "../annotations.js";
4
4
  import { CallStackDiff } from "./CallStackDiff.tsx";
5
+ import { ChangesCard } from "./ChangesCard.tsx";
5
6
  import { CodeDiff } from "./CodeDiff.tsx";
6
7
  import { FileExcerpt } from "./FileExcerpt.tsx";
7
8
  import { HtmlBlock } from "./HtmlBlock.tsx";
@@ -25,7 +26,14 @@ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
25
26
  if (!trustedHtml) return <HtmlPlaceholder />;
26
27
  return <HtmlBlock source={fence.source} />;
27
28
  }
28
- if (fence.kind === "source-diff") return undefined;
29
+ if (fence.kind === "changes")
30
+ return (
31
+ <ChangesCard
32
+ rev={fence.rev}
33
+ selectedAnnotation={props.selectedAnnotation}
34
+ onSelectAnnotation={props.onSelectAnnotation}
35
+ />
36
+ );
29
37
  if (fence.kind === "callstack")
30
38
  return (
31
39
  <CallStackDiff
@@ -1,19 +1,80 @@
1
- import { useEffect, useMemo, useRef } from "react";
2
- import { backgroundColor, colors, fontFamily } from "maui";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ Button,
4
+ Close,
5
+ Dialog,
6
+ Maximize,
7
+ backgroundColor,
8
+ colors,
9
+ fontFamily,
10
+ spacing,
11
+ } from "maui";
3
12
  import { style, useStyles } from "purse-styles";
4
13
  import { mermaidSvg } from "../mermaid.js";
5
14
  import type { DiagramAnnotation } from "../diagramAnnotations.js";
6
15
  import type { SourceNavigation } from "../annotations.js";
7
16
  import { linkDiagram } from "./diagramLinks.js";
8
17
 
9
- export function MermaidBlock(
10
- props: {
11
- source: string;
12
- annotations: DiagramAnnotation[];
13
- } & SourceNavigation,
14
- ) {
18
+ type MermaidProps = {
19
+ source: string;
20
+ annotations: DiagramAnnotation[];
21
+ } & SourceNavigation;
22
+
23
+ export function MermaidBlock(props: MermaidProps) {
24
+ const [fullscreen, setFullscreen] = useState(false);
25
+ const frame = useStyles(styles.frame);
26
+ const expand = useStyles(styles.expand);
27
+ const dialog = useStyles(styles.dialog);
28
+ const header = useStyles(styles.header);
29
+ return (
30
+ <div className={frame}>
31
+ <MermaidDiagram {...props} fullscreen={false} />
32
+ <Button
33
+ className={expand}
34
+ size="sm"
35
+ variant="quiet"
36
+ aria-label="Full screen"
37
+ onClick={() => setFullscreen(true)}
38
+ >
39
+ <Maximize size="xs" />
40
+ </Button>
41
+ <Dialog
42
+ isOpen={fullscreen}
43
+ onOpenChange={setFullscreen}
44
+ size="lg"
45
+ aria-label="Diagram"
46
+ className={dialog}
47
+ >
48
+ <div className={header}>
49
+ <Button
50
+ size="sm"
51
+ variant="quiet"
52
+ slot="close"
53
+ aria-label="Exit full screen"
54
+ >
55
+ <Close size="xs" />
56
+ </Button>
57
+ </div>
58
+ <MermaidDiagram
59
+ {...props}
60
+ fullscreen
61
+ onSelectAnnotation={(annotation) => {
62
+ // The Diff panel opens behind the dialog, so leave full screen.
63
+ setFullscreen(false);
64
+ props.onSelectAnnotation(annotation);
65
+ }}
66
+ />
67
+ </Dialog>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
15
73
  const container = useRef<HTMLDivElement>(null);
16
- const shell = useStyles(styles.shell);
74
+ const shell = useStyles(
75
+ styles.shell,
76
+ props.fullscreen ? styles.fullscreenShell : styles.inlineShell,
77
+ );
17
78
  const svg = useMemo(
18
79
  () =>
19
80
  mermaidSvg({
@@ -91,9 +152,37 @@ export function MermaidBlock(
91
152
  }
92
153
 
93
154
  const styles = {
94
- shell: style({
155
+ frame: style({
156
+ position: "relative",
95
157
  width: "100%",
158
+ minWidth: 0,
159
+ }),
160
+ expand: style({
161
+ position: "absolute",
162
+ top: 0,
163
+ insetInlineEnd: 0,
164
+ }),
165
+ dialog: style({
166
+ width: "calc(100vw - 32px)",
167
+ height: "calc(100dvh - 32px)",
168
+ padding: spacing.value(6),
169
+ "& > [role='dialog']": { flex: 1, minHeight: 0, gap: spacing.value(2) },
170
+ }),
171
+ header: style({
172
+ display: "flex",
173
+ justifyContent: "flex-end",
174
+ }),
175
+ inlineShell: style({
96
176
  maxHeight: "60vh",
177
+ "& svg": { maxHeight: "60vh" },
178
+ }),
179
+ fullscreenShell: style({
180
+ flex: 1,
181
+ "& svg": { height: "100%" },
182
+ }),
183
+ shell: style({
184
+ width: "100%",
185
+ minHeight: 0,
97
186
  overflow: "auto",
98
187
  minWidth: 0,
99
188
  border: 0,
@@ -120,7 +209,6 @@ const styles = {
120
209
  display: "block",
121
210
  width: "100%",
122
211
  height: "auto",
123
- maxHeight: "60vh",
124
212
  },
125
213
  }),
126
214
  };