@tanishqkancharla/diffmap 0.1.4 → 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 (53) hide show
  1. package/README.md +69 -61
  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.test.ts +50 -0
  7. package/src/cli.ts +41 -5
  8. package/src/errors.ts +10 -0
  9. package/src/gist/GistApp.tsx +39 -20
  10. package/src/gist/GistStatus.tsx +2 -51
  11. package/src/gist/fetchGist.ts +23 -0
  12. package/src/git.test.ts +402 -0
  13. package/src/git.ts +498 -0
  14. package/src/github/GitHubApp.tsx +39 -22
  15. package/src/github/fetchRepo.test.ts +59 -1
  16. package/src/github/fetchRepo.ts +99 -43
  17. package/src/github/patch.test.ts +18 -9
  18. package/src/github/patch.ts +30 -19
  19. package/src/host/HostHome.tsx +86 -0
  20. package/src/host/files.ts +10 -0
  21. package/src/host/home.md +156 -0
  22. package/src/host/home.test.ts +83 -0
  23. package/src/index.ts +1 -0
  24. package/src/packageVersion.ts +7 -0
  25. package/src/parseFence.ts +6 -6
  26. package/src/parseViewer.test.ts +87 -0
  27. package/src/parseViewer.ts +103 -77
  28. package/src/serve.test.ts +306 -0
  29. package/src/serve.ts +48 -5
  30. package/src/share.ts +18 -0
  31. package/src/update.test.ts +19 -0
  32. package/src/update.ts +285 -0
  33. package/src/viewer/CallStackDiff.tsx +15 -3
  34. package/src/viewer/ChangesCard.tsx +267 -0
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/ComarkView.tsx +83 -15
  37. package/src/viewer/Fence.tsx +9 -1
  38. package/src/viewer/FileExcerpt.tsx +25 -3
  39. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  40. package/src/viewer/MermaidBlock.tsx +99 -11
  41. package/src/viewer/RangePicker.tsx +526 -0
  42. package/src/viewer/SourceDiffPanel.tsx +277 -121
  43. package/src/viewer/TableOfContents.tsx +313 -39
  44. package/src/viewer/ViewerApp.tsx +120 -68
  45. package/src/viewer/changesSource.ts +91 -0
  46. package/src/viewer/sourceHeader.test.ts +2 -87
  47. package/src/viewer/sourceHeader.ts +4 -63
  48. package/src/viewer/sourceNavigation.ts +33 -42
  49. package/src/viewer/viewerMode.ts +1 -1
  50. package/vite.config.ts +2 -1
  51. package/vite.gist.config.ts +57 -0
  52. package/src/viewer/Check.tsx +0 -24
  53. package/src/viewer/DoneButton.tsx +0 -32
@@ -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,20 +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
- import {
28
- matchingSourceDiff,
29
- requestSource,
30
- useSourceReference,
31
- } from "./sourceNavigation.js";
26
+ import { useHostFiles } from "../host/files.ts";
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";
32
31
  import { pierreDiffOptions, sourceSelectionCss } from "./pierre.js";
33
- import {
34
- sourcePanelOrigin,
35
- sourcePanelPath,
36
- sourcePanelPin,
37
- } from "./sourceHeader.ts";
32
+ import { sourcePanelPin } from "./sourceHeader.ts";
38
33
  import { useViewerMode } from "./viewerMode.ts";
39
34
 
40
35
  export type SourceSelection = {
@@ -49,7 +44,10 @@ type DefinitionNavigation = {
49
44
  };
50
45
 
51
46
  export function SourceDiffPanel(props: {
52
- 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;
53
51
  selection: SourceSelection | undefined;
54
52
  onSelect: (selection: SourceSelection) => void;
55
53
  }) {
@@ -78,86 +76,189 @@ export function SourceDiffPanel(props: {
78
76
  const pinClass = useStyles(styles.pin);
79
77
  const links = useStyles(styles.links);
80
78
  const code = useStyles(styles.code);
79
+ const summary = useStyles(styles.summary);
80
+ const codeWrap = useStyles(styles.codeWrap);
81
+ const collapseIcon = useStyles(styles.collapseIcon);
81
82
  const github = useGitHubPin();
82
83
  const gist = useGistPin();
84
+ const hostFiles = useHostFiles();
83
85
  const selection = props.selection;
84
- const source = useSourceReference(selection?.reference);
85
- const resolvedFile = source instanceof Error ? undefined : source;
86
- const linkedDiff = useMemo(
86
+ const reference = selection?.reference;
87
+ const changes = useChanges(props.rev);
88
+ const items = useMemo(
87
89
  () =>
88
- resolvedFile === undefined
89
- ? undefined
90
- : matchingSourceDiff(resolvedFile, props.items),
91
- [resolvedFile, props.items],
90
+ changes === undefined || changes instanceof Error
91
+ ? []
92
+ : changes.files.flatMap((changed) => changedFileItem(changed) ?? []),
93
+ [changes],
92
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);
93
105
  const file =
94
- definition === undefined
95
- ? linkedDiff === undefined
96
- ? resolvedFile
97
- : undefined
98
- : definition;
106
+ definition ??
107
+ (resolvedFile !== undefined && changes !== undefined && !inChanges
108
+ ? resolvedFile
109
+ : undefined);
99
110
  const status =
100
111
  navigation.status !== undefined
101
112
  ? navigation.status
102
113
  : source instanceof Error
103
114
  ? source.message
104
- : selection?.reference.kind === "file" &&
105
- source === undefined &&
106
- mode !== "gist"
107
- ? "Opening source…"
108
- : 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;
109
129
  const selectedLines = useMemo<CodeViewLineSelection | null>(() => {
110
130
  if (file !== undefined)
111
131
  return { id: file.path, range: { start: file.start, end: file.end } };
112
- if (linkedDiff !== undefined) return linkedDiff;
113
- 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);
114
141
  // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a controlled empty selection.
115
- if (ref === undefined || ref.kind === "file") return null;
142
+ if (focus === undefined) return null;
116
143
  return {
117
- id: ref.id,
118
- range: {
119
- start: ref.start,
120
- end: ref.end,
121
- side: ref.side === "old" ? "deletions" : "additions",
122
- },
144
+ id: focus.path,
145
+ range: { start: focus.start, end: focus.end, side: "additions" },
123
146
  };
124
- }, [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
+ }
159
+ const rangeId = selectedLines?.id;
160
+ const rangeStart = selectedLines?.range.start;
161
+ const rangeEnd = selectedLines?.range.end;
162
+ const rangeSide =
163
+ selectedLines !== null && "side" in selectedLines.range
164
+ ? selectedLines.range.side
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
+ };
125
215
 
126
- const openPath = sourcePanelPath({
127
- filePath: file?.path,
128
- reference: selection?.reference,
129
- items: props.items,
130
- linkedDiffId: linkedDiff?.id,
131
- });
132
- const origin = sourcePanelOrigin({
133
- showingFile: file !== undefined,
134
- reference: selection?.reference,
135
- items: props.items,
136
- linkedDiffId: linkedDiff?.id,
137
- });
138
216
  const pin =
139
- openPath === undefined
217
+ file === undefined
140
218
  ? { kind: "none" as const }
141
- : sourcePanelPin({
142
- origin,
143
- path: openPath,
144
- github,
145
- gist,
146
- });
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;
147
222
  const options = pierreDiffOptions({
148
223
  themeType: resolvedTheme,
149
- disableFileHeader: openPath !== undefined,
224
+ disableFileHeader: showTitle,
150
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]);
151
240
 
152
241
  useEffect(() => {
153
- if (selectedLines === null) return;
242
+ if (
243
+ !ready ||
244
+ rangeId === undefined ||
245
+ rangeStart === undefined ||
246
+ rangeEnd === undefined
247
+ ) {
248
+ return;
249
+ }
154
250
  viewer.current?.scrollTo({
155
251
  type: "range",
156
- ...selectedLines,
252
+ id: rangeId,
253
+ range: {
254
+ start: rangeStart,
255
+ end: rangeEnd,
256
+ ...(rangeSide === undefined ? {} : { side: rangeSide }),
257
+ },
157
258
  align: "center",
158
259
  behavior: "instant",
159
260
  });
160
- }, [selectedLines]);
261
+ }, [ready, rangeId, rangeStart, rangeEnd, rangeSide]);
161
262
 
162
263
  const onTokenClick: CodeViewOptions<undefined, undefined>["onTokenClick"] = (
163
264
  token,
@@ -214,11 +315,43 @@ export function SourceDiffPanel(props: {
214
315
 
215
316
  return (
216
317
  <aside id="source-diff-panel" aria-label="Source changes" className={panel}>
217
- <div className={header} data-diffmap-kind="source-header">
218
- {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 && (
219
325
  <div className={titleRow}>
220
- <div className={titleClass} title={openPath}>
221
- {openPath}
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 && (
352
+ <div className={titleRow}>
353
+ <div className={titleClass} title={file.path}>
354
+ {file.path}
222
355
  </div>
223
356
  {definition !== undefined && (
224
357
  <Button
@@ -257,82 +390,86 @@ export function SourceDiffPanel(props: {
257
390
  {pin.label}
258
391
  </a>
259
392
  )}
260
- {pin.kind === "spec" && (
261
- <span className={pinClass} data-diffmap-pin="spec">
262
- {pin.label}
263
- </span>
264
- )}
265
- {mode === "local" && (
266
- <span>⌘-click or Ctrl-click a symbol to go to its definition.</span>
267
- )}
268
393
  {status !== undefined && <span role="status">{status}</span>}
269
- {selection === undefined ? (
270
- <span>
271
- Select a linked stack row or diagram element to open its source.
272
- </span>
273
- ) : (
394
+ {selection !== undefined &&
274
395
  selection.annotation.references.length > 1 && (
275
396
  <div className={links} aria-label="Linked changes">
276
- {selection.annotation.references.map((reference, index) => (
397
+ {selection.annotation.references.map((option, index) => (
277
398
  <Button
278
399
  key={index}
279
400
  variantColor="blue"
280
- variant={
281
- reference === selection.reference ? "primary" : "quiet"
282
- }
401
+ variant={option === selection.reference ? "primary" : "quiet"}
283
402
  onClick={() =>
284
403
  props.onSelect({
285
404
  annotation: selection.annotation,
286
- reference,
405
+ reference: option,
287
406
  })
288
407
  }
289
408
  >
290
- {referenceLabel(reference)}
409
+ {referenceLabel(option)}
291
410
  </Button>
292
411
  ))}
293
412
  </div>
294
- )
295
- )}
413
+ )}
296
414
  </div>
297
- <CodeView
298
- ref={viewer}
299
- className={code}
300
- items={
301
- file === undefined
302
- ? props.items
303
- : [
304
- {
305
- type: "file",
306
- id: file.path,
307
- file: {
308
- name: file.path,
309
- 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
+ },
310
431
  },
311
- },
312
- ]
313
- }
314
- selectedLines={selectedLines}
315
- disableWorkerPool
316
- options={{
317
- ...options,
318
- unsafeCSS: options.unsafeCSS + sourceSelectionCss,
319
- stickyHeaders: true,
320
- onTokenClick,
321
- }}
322
- />
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>
323
457
  </aside>
324
458
  );
325
459
  }
326
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
+
327
465
  const styles = {
328
466
  panel: style(flex({ direction: "column" }), border(["left"], "border"), {
329
- gridColumn: "3",
467
+ gridArea: "diff",
330
468
  minWidth: 0,
331
469
  minHeight: 0,
332
470
  overflow: "hidden",
333
471
  backgroundColor: backgroundColor.app,
334
472
  "@media (max-width: 900px)": {
335
- gridColumn: "1",
336
473
  height: "45vh",
337
474
  borderLeft: "none",
338
475
  borderTop: `1px solid ${borderColor.border}`,
@@ -340,10 +477,14 @@ const styles = {
340
477
  }),
341
478
  header: style(
342
479
  flex({ direction: "column" }),
343
- 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" },
344
484
  border(["bottom"], "border"),
345
485
  text({ size: "sm", color: "lowContrast" }),
346
486
  {
487
+ "&[data-file-headers='true']": { borderBottom: "none" },
347
488
  gap: "6px",
348
489
  minWidth: 0,
349
490
  maxHeight: "35%",
@@ -351,7 +492,9 @@ const styles = {
351
492
  flexShrink: 0,
352
493
  },
353
494
  ),
354
- titleRow: style(flex({ direction: "row", align: "center" }), {
495
+ // Pierre's file headers pad 16px; this header pads 7px.
496
+ summary: style({ marginLeft: "auto", marginRight: "9px" }),
497
+ titleRow: style(flex({ direction: "row", alignItems: "center" }), {
355
498
  gap: "6px",
356
499
  minWidth: 0,
357
500
  }),
@@ -373,6 +516,19 @@ const styles = {
373
516
  },
374
517
  }),
375
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
+ }),
376
532
  code: style({
377
533
  flex: "1 1 auto",
378
534
  minWidth: 0,