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