@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,39 +1,216 @@
1
- import { useEffect, useState, type RefObject } from "react";
2
- import { colors, focusRing, radius, spacing, text } from "maui";
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ type RefObject,
8
+ } from "react";
9
+ import { motion, useReducedMotion, type Transition } from "motion/react";
10
+ import {
11
+ background,
12
+ colors,
13
+ focusRing,
14
+ motionEasing,
15
+ overlayMotion,
16
+ radius,
17
+ shadow,
18
+ spacing,
19
+ text,
20
+ } from "maui";
3
21
  import { style, useStyles } from "purse-styles";
4
22
  import type { ViewerHeading } from "../parseViewer.js";
5
23
 
6
24
  type TocItem = ViewerHeading & { children: TocItem[] };
7
25
 
26
+ const TOC_LINE_MAX_PX = 12;
27
+ const TOC_LINE_MIN_PX = 3;
28
+ const TOC_LINE_STEP_PX = 3;
29
+ const DWELL_MS = 50;
30
+ const LEAVE_MS = 100;
31
+ const TOC_TRANSITION_MS = 180;
32
+
33
+ // Maui's Dialog motion (no-bounce spring in, ease-in out), about 25% faster
34
+ // so the menu keeps up with hover.
35
+ const cardEnter: Transition = {
36
+ type: "spring",
37
+ visualDuration: 0.15,
38
+ bounce: overlayMotion.dialogEnter.bounce,
39
+ };
40
+ const cardExit: Transition = {
41
+ duration: 0.09,
42
+ ease: "easeIn",
43
+ };
44
+ const cardReduced: Transition = {
45
+ duration: overlayMotion.reduced.durationMs / 1000,
46
+ ease: "easeInOut",
47
+ };
48
+
49
+ /** Equal side gutters so the line rail fits and the article column stays centered. */
50
+ export const TOC_SIDE_MARGIN_PX = 48;
51
+
52
+ /**
53
+ * Left gutter width at which the list sits there as-is. Narrower gutters (a
54
+ * small window, or an open Diff panel) collapse it to lines and a hover card.
55
+ */
56
+ const TOC_DOCKED_MIN_GUTTER_PX = 220;
57
+
58
+ export function hasTableOfContents(headings: ViewerHeading[]) {
59
+ return tocHeadings(headings).length > 0;
60
+ }
61
+
62
+ export function tocLineWidth(level: number, shallowestLevel: number) {
63
+ const depth = Math.max(0, level - shallowestLevel);
64
+ return Math.max(TOC_LINE_MIN_PX, TOC_LINE_MAX_PX - depth * TOC_LINE_STEP_PX);
65
+ }
66
+
8
67
  export function TableOfContents(props: {
9
68
  headings: ViewerHeading[];
10
69
  articleRef: RefObject<HTMLElement | null>;
11
70
  }) {
12
- const items = nestHeadings(tocHeadings(props.headings));
71
+ const headings = tocHeadings(props.headings);
72
+ const items = nestHeadings(headings);
13
73
  const { activeId, setActiveId } = useActiveHeading(
14
74
  props.headings,
15
75
  props.articleRef,
16
76
  );
17
- const navClass = useStyles(styles.nav);
77
+ const navRef = useRef<HTMLElement>(null);
78
+ const docked = useDocked(navRef);
79
+ const railClass = useStyles(styles.rail);
80
+ const dockedClass = useStyles(styles.docked);
18
81
  const listClass = useStyles(styles.list);
82
+
19
83
  if (items.length === 0) return undefined;
20
84
 
85
+ const list = (
86
+ <TocList
87
+ items={items}
88
+ className={listClass}
89
+ activeId={activeId}
90
+ onSelect={setActiveId}
91
+ />
92
+ );
93
+
21
94
  return (
22
95
  <nav
23
- className={navClass}
96
+ ref={navRef}
97
+ id="diffmap-toc"
98
+ className={railClass}
24
99
  aria-label="Table of contents"
25
100
  data-diffmap-kind="toc"
101
+ data-docked={docked ? "true" : "false"}
26
102
  >
27
- <TocList
28
- items={items}
29
- className={listClass}
30
- activeId={activeId}
31
- onSelect={setActiveId}
32
- />
103
+ {docked ? (
104
+ <div className={dockedClass} data-diffmap-kind="toc-list">
105
+ {list}
106
+ </div>
107
+ ) : (
108
+ <TocRail headings={headings} activeId={activeId}>
109
+ {list}
110
+ </TocRail>
111
+ )}
33
112
  </nav>
34
113
  );
35
114
  }
36
115
 
116
+ /** Heading lines that open the list in a floating card on hover. */
117
+ function TocRail(props: {
118
+ headings: ViewerHeading[];
119
+ activeId: string | undefined;
120
+ children: ReactNode;
121
+ }) {
122
+ const clusterClass = useStyles(styles.cluster);
123
+ const lineClass = useStyles(styles.line);
124
+ const cardClass = useStyles(styles.card);
125
+ const reduceMotion = Boolean(useReducedMotion());
126
+ const [open, setOpen] = useState(false);
127
+ const hoveringRef = useRef(false);
128
+ const dwellTimer = useRef<number | undefined>(undefined);
129
+ const closeTimer = useRef<number | undefined>(undefined);
130
+
131
+ useEffect(() => {
132
+ const onPageLeave = () => {
133
+ // Leaving the window must close a movement-opened nav, never open one.
134
+ // A left-edge exit used to land in the dwell zone and spring the drawer.
135
+ hoveringRef.current = false;
136
+ window.clearTimeout(dwellTimer.current);
137
+ window.clearTimeout(closeTimer.current);
138
+ dwellTimer.current = undefined;
139
+ closeTimer.current = undefined;
140
+ setOpen(false);
141
+ };
142
+ document.documentElement.addEventListener("mouseleave", onPageLeave);
143
+ return () => {
144
+ document.documentElement.removeEventListener("mouseleave", onPageLeave);
145
+ window.clearTimeout(dwellTimer.current);
146
+ window.clearTimeout(closeTimer.current);
147
+ };
148
+ }, []);
149
+
150
+ const shallowest = props.headings.reduce(
151
+ (min, heading) => Math.min(min, heading.level),
152
+ 6,
153
+ );
154
+
155
+ return (
156
+ <div
157
+ className={clusterClass}
158
+ data-open={open ? "true" : "false"}
159
+ onPointerEnter={() => {
160
+ hoveringRef.current = true;
161
+ window.clearTimeout(closeTimer.current);
162
+ closeTimer.current = undefined;
163
+ if (open || dwellTimer.current !== undefined) return;
164
+ dwellTimer.current = window.setTimeout(() => {
165
+ dwellTimer.current = undefined;
166
+ if (!hoveringRef.current) return;
167
+ setOpen(true);
168
+ }, DWELL_MS);
169
+ }}
170
+ onPointerLeave={() => {
171
+ hoveringRef.current = false;
172
+ window.clearTimeout(dwellTimer.current);
173
+ dwellTimer.current = undefined;
174
+ window.clearTimeout(closeTimer.current);
175
+ closeTimer.current = window.setTimeout(() => {
176
+ closeTimer.current = undefined;
177
+ if (hoveringRef.current) return;
178
+ setOpen(false);
179
+ }, LEAVE_MS);
180
+ }}
181
+ >
182
+ {props.headings.map((heading) => (
183
+ <span
184
+ key={heading.id}
185
+ className={lineClass}
186
+ data-diffmap-kind="toc-line"
187
+ data-level={heading.level}
188
+ data-current={props.activeId === heading.id ? "true" : "false"}
189
+ style={{ width: tocLineWidth(heading.level, shallowest) }}
190
+ />
191
+ ))}
192
+ <motion.div
193
+ className={cardClass}
194
+ // Motion owns transform, so it also applies the vertical centering.
195
+ style={{ y: "-50%" }}
196
+ initial={false}
197
+ animate={
198
+ open
199
+ ? { opacity: 1, scale: 1 }
200
+ : { opacity: 0, scale: reduceMotion ? 1 : 0.94 }
201
+ }
202
+ transition={reduceMotion ? cardReduced : open ? cardEnter : cardExit}
203
+ data-diffmap-kind="toc-card"
204
+ data-open={open ? "true" : "false"}
205
+ aria-hidden={!open}
206
+ inert={!open}
207
+ >
208
+ {props.children}
209
+ </motion.div>
210
+ </div>
211
+ );
212
+ }
213
+
37
214
  function TocList(props: {
38
215
  items: TocItem[];
39
216
  className: string;
@@ -117,6 +294,29 @@ function nestHeadings(headings: ViewerHeading[]): TocItem[] {
117
294
  return items;
118
295
  }
119
296
 
297
+ /**
298
+ * The nav fills the gutter column in both modes, so its width is the gutter's
299
+ * and does not depend on which mode renders inside it.
300
+ */
301
+ function useDocked(navRef: RefObject<HTMLElement | null>) {
302
+ const [docked, setDocked] = useState(false);
303
+
304
+ useLayoutEffect(() => {
305
+ const node = navRef.current;
306
+ if (node === null) return;
307
+ const measure = () => {
308
+ if (node.clientWidth === 0) return;
309
+ setDocked(node.clientWidth >= TOC_DOCKED_MIN_GUTTER_PX);
310
+ };
311
+ measure();
312
+ const observer = new ResizeObserver(measure);
313
+ observer.observe(node);
314
+ return () => observer.disconnect();
315
+ }, [navRef]);
316
+
317
+ return docked;
318
+ }
319
+
120
320
  function useActiveHeading(
121
321
  headings: ViewerHeading[],
122
322
  articleRef: RefObject<HTMLElement | null>,
@@ -150,40 +350,110 @@ function useActiveHeading(
150
350
  return { activeId, setActiveId };
151
351
  }
152
352
 
353
+ const tocListRules = {
354
+ "& ol": {
355
+ listStyle: "none",
356
+ counterReset: "none",
357
+ margin: 0,
358
+ padding: 0,
359
+ },
360
+ "& ol ol": {
361
+ paddingInlineStart: spacing.value(6),
362
+ },
363
+ "& ol > li::before": {
364
+ content: "none",
365
+ },
366
+ "& a": {
367
+ fontWeight: 400,
368
+ textDecoration: "none",
369
+ },
370
+ } as const;
371
+
153
372
  const styles = {
154
- nav: style(spacing.padding({ left: 16, right: 6, top: 8, bottom: 8 }), {
155
- boxSizing: "border-box",
373
+ rail: style({
374
+ // Sit in the prose grid's left gutter (column 1). Lines share that outer
375
+ // edge and grow inward. Sticky height keeps them in view, off the
376
+ // scrollbar, and on the article when Diff is open.
156
377
  gridColumn: "1",
157
- alignSelf: "stretch",
158
- minWidth: "240px",
159
- width: "max-content",
160
- maxWidth: "280px",
161
- minHeight: 0,
162
- height: "fit-content",
163
- maxHeight: "100%",
164
- marginTop: "auto",
165
- marginBottom: "auto",
166
- overflowY: "auto",
167
- backgroundColor: "transparent",
168
- "@media (max-width: 1100px)": {
169
- display: "none",
170
- },
171
- "&[data-diffmap-kind='toc'] ol": {
172
- listStyle: "none",
173
- counterReset: "none",
174
- margin: 0,
175
- padding: 0,
378
+ gridRow: "1",
379
+ position: "sticky",
380
+ top: 0,
381
+ alignSelf: "start",
382
+ justifySelf: "stretch",
383
+ boxSizing: "border-box",
384
+ width: "100%",
385
+ minWidth: 0,
386
+ height: "100cqh",
387
+ zIndex: 3,
388
+ display: "flex",
389
+ flexDirection: "column",
390
+ justifyContent: "center",
391
+ alignItems: "flex-start",
392
+ pointerEvents: "none",
393
+ }),
394
+ cluster: style(spacing.padding({ top: 4, bottom: 4, left: 2, right: 6 }), {
395
+ position: "relative",
396
+ minWidth: "40px",
397
+ display: "flex",
398
+ flexDirection: "column",
399
+ alignItems: "flex-start",
400
+ gap: "12px",
401
+ pointerEvents: "auto",
402
+ "&[data-open='true'] [data-diffmap-kind='toc-line']": {
403
+ opacity: 0,
176
404
  },
177
- "&[data-diffmap-kind='toc'] ol ol": {
178
- paddingInlineStart: spacing.value(6),
405
+ }),
406
+ line: style({
407
+ display: "block",
408
+ height: "1px",
409
+ flexShrink: 0,
410
+ borderRadius: "999px",
411
+ backgroundColor: colors.gray[8],
412
+ opacity: 1,
413
+ transition: `opacity ${String(TOC_TRANSITION_MS)}ms ${motionEasing}`,
414
+ "@media (prefers-reduced-motion: reduce)": {
415
+ transition: "none",
179
416
  },
180
- "&[data-diffmap-kind='toc'] ol > li::before": {
181
- content: "none",
417
+ "&[data-current='true']": {
418
+ backgroundColor: colors.gray[12],
182
419
  },
183
- "&[data-diffmap-kind='toc'] a": {
184
- fontWeight: 400,
185
- textDecoration: "none",
420
+ }),
421
+ card: style(
422
+ radius.lg,
423
+ shadow.medium,
424
+ background.element,
425
+ spacing.padding({ x: 3, y: 3 }),
426
+ {
427
+ position: "absolute",
428
+ insetInlineStart: 0,
429
+ top: "50%",
430
+ zIndex: 1,
431
+ boxSizing: "border-box",
432
+ width: "min(240px, calc(100cqw - 24px))",
433
+ maxHeight: "min(32rem, calc(100cqh - 24px))",
434
+ overflowX: "hidden",
435
+ overflowY: "auto",
436
+ overscrollBehavior: "contain",
437
+ transformOrigin: "left center",
438
+ pointerEvents: "none",
439
+ "&[data-open='true']": {
440
+ pointerEvents: "auto",
441
+ },
442
+ ...tocListRules,
186
443
  },
444
+ ),
445
+ // The same list as the card, laid straight into the gutter: no surface,
446
+ // shadow, or hover-to-open. Stays inside the gutter so it never covers prose.
447
+ docked: style(spacing.padding({ right: 6 }), {
448
+ boxSizing: "border-box",
449
+ width: "100%",
450
+ maxWidth: "240px",
451
+ maxHeight: "calc(100cqh - 24px)",
452
+ overflowX: "hidden",
453
+ overflowY: "auto",
454
+ overscrollBehavior: "contain",
455
+ pointerEvents: "auto",
456
+ ...tocListRules,
187
457
  }),
188
458
  list: style(text({ size: "sm", fontWeight: 400, color: "lowContrast" })),
189
459
  item: style({
@@ -206,6 +476,10 @@ const styles = {
206
476
  "&[aria-current='location']": {
207
477
  color: colors.accent[9],
208
478
  fontWeight: 500,
479
+ backgroundColor: colors.accent[3],
480
+ },
481
+ "&[aria-current='location']:hover": {
482
+ backgroundColor: colors.accent[4],
209
483
  },
210
484
  },
211
485
  ),