@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
@@ -1,15 +1,13 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
2
2
  import { CodeView, type CodeViewHandle } from "@pierre/diffs/react";
3
- import type {
4
- CodeViewDiffItem,
5
- CodeViewOptions,
6
- CodeViewLineSelection,
7
- } from "@pierre/diffs";
3
+ import type { CodeViewOptions, CodeViewLineSelection } from "@pierre/diffs";
8
4
  import {
9
5
  backgroundColor,
10
6
  border,
11
7
  borderColor,
8
+ colors,
12
9
  Button,
10
+ ChevronDown,
13
11
  flex,
14
12
  spacing,
15
13
  text,
@@ -21,21 +19,17 @@ import {
21
19
  type SourceAnnotation,
22
20
  type SourceReference,
23
21
  } from "../annotations.js";
22
+ import { changedFileItem, type Rev } from "../changes.js";
24
23
  import type { SourceDefinition } from "../definitions.js";
25
24
  import { useGistPin } from "../gist/pin.ts";
26
25
  import { useGitHubPin } from "../github/pin.ts";
27
26
  import { useHostFiles } from "../host/files.ts";
28
- import {
29
- matchingSourceDiff,
30
- requestSource,
31
- useSourceReference,
32
- } from "./sourceNavigation.js";
27
+ import { ChangesSummary } from "./ChangesCard.tsx";
28
+ import { useChanges } from "./changesSource.ts";
29
+ import { RangePicker } from "./RangePicker.tsx";
30
+ import { requestSource, useSourceReference } from "./sourceNavigation.js";
33
31
  import { pierreDiffOptions, sourceSelectionCss } from "./pierre.js";
34
- import {
35
- sourcePanelOrigin,
36
- sourcePanelPath,
37
- sourcePanelPin,
38
- } from "./sourceHeader.ts";
32
+ import { sourcePanelPin } from "./sourceHeader.ts";
39
33
  import { useViewerMode } from "./viewerMode.ts";
40
34
 
41
35
  export type SourceSelection = {
@@ -50,7 +44,10 @@ type DefinitionNavigation = {
50
44
  };
51
45
 
52
46
  export function SourceDiffPanel(props: {
53
- items: CodeViewDiffItem[];
47
+ /** The changes to show; references with their own rev open that rev instead. */
48
+ rev: Rev;
49
+ /** Local viewers pick other changes with the range picker. */
50
+ onRevChange?: (rev: Rev) => void;
54
51
  selection: SourceSelection | undefined;
55
52
  onSelect: (selection: SourceSelection) => void;
56
53
  }) {
@@ -79,51 +76,87 @@ export function SourceDiffPanel(props: {
79
76
  const pinClass = useStyles(styles.pin);
80
77
  const links = useStyles(styles.links);
81
78
  const code = useStyles(styles.code);
79
+ const summary = useStyles(styles.summary);
80
+ const codeWrap = useStyles(styles.codeWrap);
81
+ const collapseIcon = useStyles(styles.collapseIcon);
82
82
  const github = useGitHubPin();
83
83
  const gist = useGistPin();
84
84
  const hostFiles = useHostFiles();
85
85
  const selection = props.selection;
86
- const source = useSourceReference(selection?.reference);
87
- const resolvedFile = source instanceof Error ? undefined : source;
88
- const linkedDiff = useMemo(
86
+ const reference = selection?.reference;
87
+ const changes = useChanges(props.rev);
88
+ const items = useMemo(
89
89
  () =>
90
- resolvedFile === undefined
91
- ? undefined
92
- : matchingSourceDiff(resolvedFile, props.items),
93
- [resolvedFile, props.items],
90
+ changes === undefined || changes instanceof Error
91
+ ? []
92
+ : changes.files.flatMap((changed) => changedFileItem(changed) ?? []),
93
+ [changes],
94
94
  );
95
+ // A rev-less reference resolves against current files; a commit reference names its lines directly.
96
+ const source = useSourceReference(
97
+ reference?.kind === "file" && reference.rev === undefined
98
+ ? reference
99
+ : undefined,
100
+ );
101
+ const resolvedFile = source instanceof Error ? undefined : source;
102
+ const inChanges =
103
+ resolvedFile !== undefined &&
104
+ items.some((item) => item.id === resolvedFile.path);
95
105
  const file =
96
- definition === undefined
97
- ? linkedDiff === undefined
98
- ? resolvedFile
99
- : undefined
100
- : definition;
106
+ definition ??
107
+ (resolvedFile !== undefined && changes !== undefined && !inChanges
108
+ ? resolvedFile
109
+ : undefined);
101
110
  const status =
102
111
  navigation.status !== undefined
103
112
  ? navigation.status
104
113
  : source instanceof Error
105
114
  ? source.message
106
- : selection?.reference.kind === "file" &&
107
- source === undefined &&
108
- (mode === "local" || mode === "github" || hostFiles !== undefined)
109
- ? "Opening source…"
110
- : undefined;
115
+ : // A shown file replaces the changes, so their error is moot, as in a gist without uncommitted changes.
116
+ changes instanceof Error && file === undefined
117
+ ? changes.message
118
+ : changes === undefined
119
+ ? "Loading changes…"
120
+ : reference?.kind === "file" &&
121
+ reference.rev === undefined &&
122
+ source === undefined &&
123
+ (mode === "local" ||
124
+ mode === "github" ||
125
+ hostFiles !== undefined)
126
+ ? "Opening source…"
127
+ : items.length === 0 && file === undefined
128
+ ? "No changes."
129
+ : undefined;
111
130
  const selectedLines = useMemo<CodeViewLineSelection | null>(() => {
112
131
  if (file !== undefined)
113
132
  return { id: file.path, range: { start: file.start, end: file.end } };
114
- if (linkedDiff !== undefined) return linkedDiff;
115
- const ref = selection?.reference;
133
+ const focus =
134
+ resolvedFile ??
135
+ (reference?.kind === "file" && reference.start !== undefined
136
+ ? {
137
+ path: reference.path,
138
+ start: reference.start,
139
+ end: reference.end ?? reference.start,
140
+ }
141
+ : undefined);
116
142
  // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a controlled empty selection.
117
- if (ref === undefined || ref.kind === "file") return null;
143
+ if (focus === undefined) return null;
118
144
  return {
119
- id: ref.id,
120
- range: {
121
- start: ref.start,
122
- end: ref.end,
123
- side: ref.side === "old" ? "deletions" : "additions",
124
- },
145
+ id: focus.path,
146
+ range: { start: focus.start, end: focus.end, side: "additions" },
125
147
  };
126
- }, [selection, file, linkedDiff]);
148
+ }, [reference, file, resolvedFile]);
149
+ const focusedPath =
150
+ file?.path ?? (reference?.kind === "file" ? reference.path : undefined);
151
+ // Collapsed files reset when the rev or the selected reference changes, so a reference always shows its file.
152
+ const [collapse, setCollapse] = useState<{
153
+ rev: Rev;
154
+ selection: SourceSelection | undefined;
155
+ paths: ReadonlySet<string>;
156
+ }>({ rev: props.rev, selection, paths: new Set() });
157
+ if (collapse.rev !== props.rev || collapse.selection !== selection) {
158
+ setCollapse({ rev: props.rev, selection, paths: new Set() });
159
+ }
127
160
  const rangeId = selectedLines?.id;
128
161
  const rangeStart = selectedLines?.range.start;
129
162
  const rangeEnd = selectedLines?.range.end;
@@ -131,35 +164,84 @@ export function SourceDiffPanel(props: {
131
164
  selectedLines !== null && "side" in selectedLines.range
132
165
  ? selectedLines.range.side
133
166
  : undefined;
167
+ const shownItems = useMemo(
168
+ () =>
169
+ items.map((item) => {
170
+ // The referenced file shows enough unchanged context to reveal its range.
171
+ const changed =
172
+ item.id === rangeId &&
173
+ rangeSide === "additions" &&
174
+ changes !== undefined &&
175
+ !(changes instanceof Error)
176
+ ? changes.files.find((entry) => entry.path === item.id)
177
+ : undefined;
178
+ const shown =
179
+ changed === undefined
180
+ ? item
181
+ : (changedFileItem(changed, {
182
+ start: rangeStart!,
183
+ end: rangeEnd!,
184
+ }) ?? item);
185
+ const collapsed = collapse.paths.has(item.id);
186
+ // Pierre only rereads an item when its version changes.
187
+ return {
188
+ ...shown,
189
+ collapsed,
190
+ version: (shown.version ?? 0) * 2 + (collapsed ? 1 : 0),
191
+ };
192
+ }),
193
+ [items, changes, collapse.paths, rangeId, rangeStart, rangeEnd, rangeSide],
194
+ );
195
+ const toggleCollapsed = (path: string) => {
196
+ const paths = new Set(collapse.paths);
197
+ if (!paths.delete(path)) paths.add(path);
198
+ setCollapse({ ...collapse, paths });
199
+ };
200
+ // File headers live in Pierre's shadow DOM; a click inside one names its file through the prefix slot.
201
+ const onHeaderClick = (event: MouseEvent) => {
202
+ if (file !== undefined) return;
203
+ const fileHeader = event.nativeEvent
204
+ .composedPath()
205
+ .find(
206
+ (target): target is Element =>
207
+ target instanceof Element && target.hasAttribute("data-diffs-header"),
208
+ );
209
+ const root = fileHeader?.getRootNode();
210
+ if (!(root instanceof ShadowRoot)) return;
211
+ const path = root.host
212
+ .querySelector("[data-collapse-path]")
213
+ ?.getAttribute("data-collapse-path");
214
+ if (path !== null && path !== undefined) toggleCollapsed(path);
215
+ };
134
216
 
135
- const openPath = sourcePanelPath({
136
- filePath: file?.path,
137
- reference: selection?.reference,
138
- items: props.items,
139
- linkedDiffId: linkedDiff?.id,
140
- });
141
- const origin = sourcePanelOrigin({
142
- showingFile: file !== undefined,
143
- reference: selection?.reference,
144
- items: props.items,
145
- linkedDiffId: linkedDiff?.id,
146
- });
147
217
  const pin =
148
- openPath === undefined
218
+ file === undefined
149
219
  ? { kind: "none" as const }
150
- : sourcePanelPin({
151
- origin,
152
- path: openPath,
153
- github,
154
- gist,
155
- });
220
+ : sourcePanelPin({ path: file.path, github, gist });
221
+ // Diffs keep Pierre's sticky per-file headers; a single file shows its path in the panel title.
222
+ const showTitle = file !== undefined;
156
223
  const options = pierreDiffOptions({
157
224
  themeType: resolvedTheme,
158
- disableFileHeader: openPath !== undefined,
225
+ disableFileHeader: showTitle,
159
226
  });
227
+ const loadedChanges =
228
+ changes === undefined || changes instanceof Error ? undefined : changes;
229
+
230
+ // Scroll again once the changes arrive, since Pierre cannot scroll to items it does not have yet.
231
+ const ready = file !== undefined || loadedChanges !== undefined;
232
+ useEffect(() => {
233
+ if (!ready || focusedPath === undefined || rangeId !== undefined) return;
234
+ viewer.current?.scrollTo({
235
+ type: "item",
236
+ id: focusedPath,
237
+ align: "start",
238
+ behavior: "instant",
239
+ });
240
+ }, [ready, focusedPath, rangeId]);
160
241
 
161
242
  useEffect(() => {
162
243
  if (
244
+ !ready ||
163
245
  rangeId === undefined ||
164
246
  rangeStart === undefined ||
165
247
  rangeEnd === undefined
@@ -177,7 +259,7 @@ export function SourceDiffPanel(props: {
177
259
  align: "center",
178
260
  behavior: "instant",
179
261
  });
180
- }, [rangeId, rangeStart, rangeEnd, rangeSide]);
262
+ }, [ready, rangeId, rangeStart, rangeEnd, rangeSide]);
181
263
 
182
264
  const onTokenClick: CodeViewOptions<undefined, undefined>["onTokenClick"] = (
183
265
  token,
@@ -234,11 +316,43 @@ export function SourceDiffPanel(props: {
234
316
 
235
317
  return (
236
318
  <aside id="source-diff-panel" aria-label="Source changes" className={panel}>
237
- <div className={header} data-diffmap-kind="source-header">
238
- {openPath !== undefined && (
319
+ <div
320
+ className={header}
321
+ data-diffmap-kind="source-header"
322
+ // Pierre's file headers draw their own top border, which divides them from this header.
323
+ data-file-headers={!showTitle && items.length > 0}
324
+ >
325
+ {!showTitle && props.onRevChange !== undefined && (
326
+ <div className={titleRow}>
327
+ <RangePicker rev={props.rev} onChange={props.onRevChange} />
328
+ {loadedChanges !== undefined && (
329
+ <ChangesSummary className={summary} changes={loadedChanges} />
330
+ )}
331
+ </div>
332
+ )}
333
+ {!showTitle &&
334
+ props.onRevChange === undefined &&
335
+ loadedChanges !== undefined && (
336
+ <div className={titleRow}>
337
+ {loadedChanges.url === undefined ? (
338
+ <div className={titleClass} title={loadedChanges.title}>
339
+ {loadedChanges.title}
340
+ </div>
341
+ ) : (
342
+ <a
343
+ className={titleClass}
344
+ href={loadedChanges.url}
345
+ title={loadedChanges.title}
346
+ >
347
+ {loadedChanges.title}
348
+ </a>
349
+ )}
350
+ </div>
351
+ )}
352
+ {showTitle && (
239
353
  <div className={titleRow}>
240
- <div className={titleClass} title={openPath}>
241
- {openPath}
354
+ <div className={titleClass} title={file.path}>
355
+ {file.path}
242
356
  </div>
243
357
  {definition !== undefined && (
244
358
  <Button
@@ -277,73 +391,78 @@ export function SourceDiffPanel(props: {
277
391
  {pin.label}
278
392
  </a>
279
393
  )}
280
- {pin.kind === "spec" && (
281
- <span className={pinClass} data-diffmap-pin="spec">
282
- {pin.label}
283
- </span>
284
- )}
285
- {mode === "local" && (
286
- <span>⌘-click or Ctrl-click a symbol to go to its definition.</span>
287
- )}
288
394
  {status !== undefined && <span role="status">{status}</span>}
289
- {selection === undefined ? (
290
- <span>
291
- Select a linked stack row or diagram element to open its source.
292
- </span>
293
- ) : (
395
+ {selection !== undefined &&
294
396
  selection.annotation.references.length > 1 && (
295
397
  <div className={links} aria-label="Linked changes">
296
- {selection.annotation.references.map((reference, index) => (
398
+ {selection.annotation.references.map((option, index) => (
297
399
  <Button
298
400
  key={index}
299
401
  variantColor="blue"
300
- variant={
301
- reference === selection.reference ? "primary" : "quiet"
302
- }
402
+ variant={option === selection.reference ? "primary" : "quiet"}
303
403
  onClick={() =>
304
404
  props.onSelect({
305
405
  annotation: selection.annotation,
306
- reference,
406
+ reference: option,
307
407
  })
308
408
  }
309
409
  >
310
- {referenceLabel(reference)}
410
+ {referenceLabel(option)}
311
411
  </Button>
312
412
  ))}
313
413
  </div>
314
- )
315
- )}
414
+ )}
316
415
  </div>
317
- <CodeView
318
- ref={viewer}
319
- className={code}
320
- items={
321
- file === undefined
322
- ? props.items
323
- : [
324
- {
325
- type: "file",
326
- id: file.path,
327
- file: {
328
- name: file.path,
329
- contents: file.contents,
416
+ {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Pierre's file headers are the click targets; this only catches their clicks. */}
417
+ <div className={codeWrap} onClick={onHeaderClick}>
418
+ <CodeView
419
+ ref={viewer}
420
+ className={code}
421
+ items={
422
+ file === undefined
423
+ ? shownItems
424
+ : [
425
+ {
426
+ type: "file",
427
+ id: file.path,
428
+ file: {
429
+ name: file.path,
430
+ contents: file.contents,
431
+ },
330
432
  },
331
- },
332
- ]
333
- }
334
- selectedLines={selectedLines}
335
- disableWorkerPool
336
- options={{
337
- ...options,
338
- unsafeCSS: options.unsafeCSS + sourceSelectionCss,
339
- stickyHeaders: true,
340
- onTokenClick,
341
- }}
342
- />
433
+ ]
434
+ }
435
+ selectedLines={selectedLines}
436
+ disableWorkerPool
437
+ options={{
438
+ ...options,
439
+ unsafeCSS:
440
+ options.unsafeCSS + sourceSelectionCss + panelBackgroundCss,
441
+ // Files stack flush under the header, with no gaps between them.
442
+ layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
443
+ stickyHeaders: true,
444
+ onTokenClick,
445
+ }}
446
+ renderHeaderPrefix={(item) => (
447
+ <span
448
+ className={collapseIcon}
449
+ data-collapse-path={item.id}
450
+ data-collapsed={collapse.paths.has(item.id)}
451
+ aria-hidden="true"
452
+ >
453
+ <ChevronDown size="sm" />
454
+ </span>
455
+ )}
456
+ />
457
+ </div>
343
458
  </aside>
344
459
  );
345
460
  }
346
461
 
462
+ /** Pierre paints its own white; the panel's background shows through instead. */
463
+ const panelBackgroundCss = `:host { --diffs-light-bg: ${backgroundColor.app}; --diffs-dark-bg: ${backgroundColor.app}; }
464
+ [data-diffs-header] { border-top: 1px solid ${borderColor.border}; cursor: pointer; }`;
465
+
347
466
  const styles = {
348
467
  panel: style(flex({ direction: "column" }), border(["left"], "border"), {
349
468
  gridArea: "diff",
@@ -359,10 +478,14 @@ const styles = {
359
478
  }),
360
479
  header: style(
361
480
  flex({ direction: "column" }),
362
- spacing.padding({ all: 4 }),
481
+ // Same vertical padding as the page header; the inline padding lines the
482
+ // picker's icon and label up with Pierre's file headers below.
483
+ spacing.padding({ y: 3 }),
484
+ { paddingInline: "7px" },
363
485
  border(["bottom"], "border"),
364
486
  text({ size: "sm", color: "lowContrast" }),
365
487
  {
488
+ "&[data-file-headers='true']": { borderBottom: "none" },
366
489
  gap: "6px",
367
490
  minWidth: 0,
368
491
  maxHeight: "35%",
@@ -370,6 +493,8 @@ const styles = {
370
493
  flexShrink: 0,
371
494
  },
372
495
  ),
496
+ // Pierre's file headers pad 16px; this header pads 7px.
497
+ summary: style({ marginLeft: "auto", marginRight: "9px" }),
373
498
  titleRow: style(flex({ direction: "row", alignItems: "center" }), {
374
499
  gap: "6px",
375
500
  minWidth: 0,
@@ -392,6 +517,19 @@ const styles = {
392
517
  },
393
518
  }),
394
519
  links: style(flex({ direction: "row" }), { gap: "6px", flexWrap: "wrap" }),
520
+ codeWrap: style({
521
+ display: "flex",
522
+ flexDirection: "column",
523
+ flex: "1 1 auto",
524
+ minWidth: 0,
525
+ minHeight: 0,
526
+ }),
527
+ collapseIcon: style({
528
+ display: "flex",
529
+ color: colors.gray[11],
530
+ transition: "transform 150ms ease-out",
531
+ "&[data-collapsed='true']": { transform: "rotate(-90deg)" },
532
+ }),
395
533
  code: style({
396
534
  flex: "1 1 auto",
397
535
  minWidth: 0,