@tanishqkancharla/diffmap 0.1.4 → 0.1.5

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.
@@ -5,6 +5,7 @@ import { useStyles } from "purse-styles";
5
5
  import { pierreFileOptions, pierreShell } from "./pierre.ts";
6
6
  import { useViewerMode } from "./viewerMode.ts";
7
7
  import { useGitHubPin } from "../github/pin.ts";
8
+ import { useHostFiles } from "../host/files.ts";
8
9
  import { fetchPinnedBlob } from "../github/fetchRepo.ts";
9
10
 
10
11
  type FileExcerptPayload = {
@@ -43,8 +44,10 @@ function useFileExcerpt(input: { path: string; start: number; end: number }) {
43
44
  const [excerpt, setExcerpt] = useState<FileExcerptPayload>();
44
45
  const mode = useViewerMode();
45
46
  const pin = useGitHubPin();
47
+ const hostFiles = useHostFiles();
48
+ const hostExcerpt = hostFileExcerpt(hostFiles, input);
46
49
  useEffect(() => {
47
- if (mode === "gist") return;
50
+ if (hostFiles !== undefined) return;
48
51
  if (mode === "github") {
49
52
  if (pin === undefined) return;
50
53
  // oxlint-disable-next-line typescript/no-floating-promises -- React effects cannot await; this request owns the excerpt update.
@@ -69,6 +72,7 @@ function useFileExcerpt(input: { path: string; start: number; end: number }) {
69
72
  });
70
73
  return;
71
74
  }
75
+ if (mode !== "local") return;
72
76
  const params = new URLSearchParams({
73
77
  path: input.path,
74
78
  start: String(input.start),
@@ -87,6 +91,24 @@ function useFileExcerpt(input: { path: string; start: number; end: number }) {
87
91
  .catch((cause) => {
88
92
  console.warn("diffmap file excerpt failed", cause);
89
93
  });
90
- }, [input.path, input.start, input.end, mode, pin]);
91
- return excerpt;
94
+ }, [input.path, input.start, input.end, mode, pin, hostFiles]);
95
+ return hostExcerpt ?? excerpt;
96
+ }
97
+
98
+ function hostFileExcerpt(
99
+ files: Record<string, string> | undefined,
100
+ input: { path: string; start: number; end: number },
101
+ ): FileExcerptPayload | undefined {
102
+ if (files === undefined) return undefined;
103
+ const contents = files[input.path];
104
+ if (contents === undefined) return undefined;
105
+ const lines = contents.split(/\r?\n/);
106
+ const start = input.start;
107
+ const end = Math.min(input.end, lines.length);
108
+ return {
109
+ path: input.path,
110
+ start,
111
+ end,
112
+ contents: lines.slice(start - 1, end).join("\n"),
113
+ };
92
114
  }
@@ -1,10 +1,15 @@
1
1
  import { colors, radius, spacing, text } from "maui";
2
2
  import { style, useStyles } from "purse-styles";
3
+ import { useViewerMode } from "./viewerMode.ts";
3
4
 
4
5
  export function HtmlPlaceholder(props: { inline?: boolean }) {
5
6
  const inline = props.inline === true;
6
7
  const className = useStyles(inline ? styles.inline : styles.block);
7
- const copy = "HTML from this gist is not rendered.";
8
+ const mode = useViewerMode();
9
+ const copy =
10
+ mode === "gist"
11
+ ? "HTML from this gist is not rendered."
12
+ : "HTML is not rendered on the hosted viewer.";
8
13
  if (inline) return <span className={className}>{copy}</span>;
9
14
  return (
10
15
  <aside className={className} data-diffmap-kind="html-disabled">
@@ -24,6 +24,7 @@ import {
24
24
  import type { SourceDefinition } from "../definitions.js";
25
25
  import { useGistPin } from "../gist/pin.ts";
26
26
  import { useGitHubPin } from "../github/pin.ts";
27
+ import { useHostFiles } from "../host/files.ts";
27
28
  import {
28
29
  matchingSourceDiff,
29
30
  requestSource,
@@ -80,6 +81,7 @@ export function SourceDiffPanel(props: {
80
81
  const code = useStyles(styles.code);
81
82
  const github = useGitHubPin();
82
83
  const gist = useGistPin();
84
+ const hostFiles = useHostFiles();
83
85
  const selection = props.selection;
84
86
  const source = useSourceReference(selection?.reference);
85
87
  const resolvedFile = source instanceof Error ? undefined : source;
@@ -103,7 +105,7 @@ export function SourceDiffPanel(props: {
103
105
  ? source.message
104
106
  : selection?.reference.kind === "file" &&
105
107
  source === undefined &&
106
- mode !== "gist"
108
+ (mode === "local" || mode === "github" || hostFiles !== undefined)
107
109
  ? "Opening source…"
108
110
  : undefined;
109
111
  const selectedLines = useMemo<CodeViewLineSelection | null>(() => {
@@ -122,6 +124,13 @@ export function SourceDiffPanel(props: {
122
124
  },
123
125
  };
124
126
  }, [selection, file, linkedDiff]);
127
+ const rangeId = selectedLines?.id;
128
+ const rangeStart = selectedLines?.range.start;
129
+ const rangeEnd = selectedLines?.range.end;
130
+ const rangeSide =
131
+ selectedLines !== null && "side" in selectedLines.range
132
+ ? selectedLines.range.side
133
+ : undefined;
125
134
 
126
135
  const openPath = sourcePanelPath({
127
136
  filePath: file?.path,
@@ -150,14 +159,25 @@ export function SourceDiffPanel(props: {
150
159
  });
151
160
 
152
161
  useEffect(() => {
153
- if (selectedLines === null) return;
162
+ if (
163
+ rangeId === undefined ||
164
+ rangeStart === undefined ||
165
+ rangeEnd === undefined
166
+ ) {
167
+ return;
168
+ }
154
169
  viewer.current?.scrollTo({
155
170
  type: "range",
156
- ...selectedLines,
171
+ id: rangeId,
172
+ range: {
173
+ start: rangeStart,
174
+ end: rangeEnd,
175
+ ...(rangeSide === undefined ? {} : { side: rangeSide }),
176
+ },
157
177
  align: "center",
158
178
  behavior: "instant",
159
179
  });
160
- }, [selectedLines]);
180
+ }, [rangeId, rangeStart, rangeEnd, rangeSide]);
161
181
 
162
182
  const onTokenClick: CodeViewOptions<undefined, undefined>["onTokenClick"] = (
163
183
  token,
@@ -326,13 +346,12 @@ export function SourceDiffPanel(props: {
326
346
 
327
347
  const styles = {
328
348
  panel: style(flex({ direction: "column" }), border(["left"], "border"), {
329
- gridColumn: "3",
349
+ gridArea: "diff",
330
350
  minWidth: 0,
331
351
  minHeight: 0,
332
352
  overflow: "hidden",
333
353
  backgroundColor: backgroundColor.app,
334
354
  "@media (max-width: 900px)": {
335
- gridColumn: "1",
336
355
  height: "45vh",
337
356
  borderLeft: "none",
338
357
  borderTop: `1px solid ${borderColor.border}`,
@@ -351,7 +370,7 @@ const styles = {
351
370
  flexShrink: 0,
352
371
  },
353
372
  ),
354
- titleRow: style(flex({ direction: "row", align: "center" }), {
373
+ titleRow: style(flex({ direction: "row", alignItems: "center" }), {
355
374
  gap: "6px",
356
375
  minWidth: 0,
357
376
  }),
@@ -1,35 +1,157 @@
1
- import { useEffect, useState, type RefObject } from "react";
2
- import { colors, focusRing, radius, spacing, text } from "maui";
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import {
3
+ background,
4
+ colors,
5
+ focusRing,
6
+ motionEasing,
7
+ radius,
8
+ shadow,
9
+ spacing,
10
+ text,
11
+ } from "maui";
3
12
  import { style, useStyles } from "purse-styles";
4
13
  import type { ViewerHeading } from "../parseViewer.js";
5
14
 
6
15
  type TocItem = ViewerHeading & { children: TocItem[] };
7
16
 
17
+ const TOC_LINE_MAX_PX = 24;
18
+ const TOC_LINE_MIN_PX = 8;
19
+ const TOC_LINE_STEP_PX = 6;
20
+ const DWELL_MS = 50;
21
+ const LEAVE_MS = 100;
22
+ const TOC_TRANSITION_MS = 180;
23
+ const TOC_MOTION = `opacity ${String(TOC_TRANSITION_MS)}ms ${motionEasing}, transform ${String(TOC_TRANSITION_MS)}ms ${motionEasing}`;
24
+
25
+ /** Equal side gutters so the line rail fits and the article column stays centered. */
26
+ export const TOC_SIDE_MARGIN_PX = 48;
27
+
28
+ /**
29
+ * Same cutoff that used to open the wide overlay (1101px). Applied to the
30
+ * article column's remaining width, so a resize or an open Diff panel can
31
+ * drop below it even when the viewport used to qualify.
32
+ */
33
+ export const TOC_OPEN_MIN_WIDTH_PX = 1101;
34
+
35
+ export function hasTableOfContents(headings: ViewerHeading[]) {
36
+ return tocHeadings(headings).length > 0;
37
+ }
38
+
39
+ export function tocLineWidth(level: number, shallowestLevel: number) {
40
+ const depth = Math.max(0, level - shallowestLevel);
41
+ return Math.max(TOC_LINE_MIN_PX, TOC_LINE_MAX_PX - depth * TOC_LINE_STEP_PX);
42
+ }
43
+
8
44
  export function TableOfContents(props: {
9
45
  headings: ViewerHeading[];
10
46
  articleRef: RefObject<HTMLElement | null>;
47
+ open: boolean;
48
+ onDwellOpen: () => void;
49
+ onDwellClose: () => void;
11
50
  }) {
12
- const items = nestHeadings(tocHeadings(props.headings));
51
+ const headings = tocHeadings(props.headings);
52
+ const items = nestHeadings(headings);
13
53
  const { activeId, setActiveId } = useActiveHeading(
14
54
  props.headings,
15
55
  props.articleRef,
16
56
  );
17
- const navClass = useStyles(styles.nav);
57
+ const railClass = useStyles(styles.rail);
58
+ const clusterClass = useStyles(styles.cluster);
59
+ const lineClass = useStyles(styles.line);
60
+ const cardClass = useStyles(styles.card);
18
61
  const listClass = useStyles(styles.list);
62
+ const hoveringRef = useRef(false);
63
+ const dwellTimer = useRef<number | undefined>(undefined);
64
+ const closeTimer = useRef<number | undefined>(undefined);
65
+ const onDwellOpen = props.onDwellOpen;
66
+ const onDwellClose = props.onDwellClose;
67
+
68
+ useEffect(() => {
69
+ const onPageLeave = () => {
70
+ // Leaving the window must close a movement-opened nav, never open one.
71
+ // A left-edge exit used to land in the dwell zone and spring the drawer.
72
+ hoveringRef.current = false;
73
+ window.clearTimeout(dwellTimer.current);
74
+ window.clearTimeout(closeTimer.current);
75
+ dwellTimer.current = undefined;
76
+ closeTimer.current = undefined;
77
+ onDwellClose();
78
+ };
79
+ document.documentElement.addEventListener("mouseleave", onPageLeave);
80
+ return () => {
81
+ document.documentElement.removeEventListener("mouseleave", onPageLeave);
82
+ window.clearTimeout(dwellTimer.current);
83
+ window.clearTimeout(closeTimer.current);
84
+ };
85
+ }, [onDwellClose]);
86
+
19
87
  if (items.length === 0) return undefined;
20
88
 
89
+ const shallowest = headings.reduce(
90
+ (min, heading) => Math.min(min, heading.level),
91
+ 6,
92
+ );
93
+
21
94
  return (
22
95
  <nav
23
- className={navClass}
96
+ id="diffmap-toc"
97
+ className={railClass}
24
98
  aria-label="Table of contents"
25
99
  data-diffmap-kind="toc"
100
+ data-open={props.open ? "true" : "false"}
26
101
  >
27
- <TocList
28
- items={items}
29
- className={listClass}
30
- activeId={activeId}
31
- onSelect={setActiveId}
32
- />
102
+ <div
103
+ className={clusterClass}
104
+ data-open={props.open ? "true" : "false"}
105
+ onPointerEnter={() => {
106
+ hoveringRef.current = true;
107
+ window.clearTimeout(closeTimer.current);
108
+ closeTimer.current = undefined;
109
+ if (props.open || dwellTimer.current !== undefined) return;
110
+ dwellTimer.current = window.setTimeout(() => {
111
+ dwellTimer.current = undefined;
112
+ if (!hoveringRef.current) return;
113
+ onDwellOpen();
114
+ }, DWELL_MS);
115
+ }}
116
+ onPointerLeave={() => {
117
+ hoveringRef.current = false;
118
+ window.clearTimeout(dwellTimer.current);
119
+ dwellTimer.current = undefined;
120
+ window.clearTimeout(closeTimer.current);
121
+ closeTimer.current = window.setTimeout(() => {
122
+ closeTimer.current = undefined;
123
+ if (hoveringRef.current) return;
124
+ onDwellClose();
125
+ }, LEAVE_MS);
126
+ }}
127
+ >
128
+ {headings.map((heading) => (
129
+ <span
130
+ key={heading.id}
131
+ className={lineClass}
132
+ data-diffmap-kind="toc-line"
133
+ data-level={heading.level}
134
+ data-current={activeId === heading.id ? "true" : "false"}
135
+ style={{ width: tocLineWidth(heading.level, shallowest) }}
136
+ />
137
+ ))}
138
+ <div
139
+ className={cardClass}
140
+ data-diffmap-kind="toc-card"
141
+ data-open={props.open ? "true" : "false"}
142
+ aria-hidden={!props.open}
143
+ inert={!props.open}
144
+ >
145
+ <TocList
146
+ items={items}
147
+ className={listClass}
148
+ activeId={activeId}
149
+ onSelect={(id) => {
150
+ setActiveId(id);
151
+ }}
152
+ />
153
+ </div>
154
+ </div>
33
155
  </nav>
34
156
  );
35
157
  }
@@ -150,41 +272,105 @@ function useActiveHeading(
150
272
  return { activeId, setActiveId };
151
273
  }
152
274
 
275
+ const tocListRules = {
276
+ "&[data-diffmap-kind='toc-card'] ol": {
277
+ listStyle: "none",
278
+ counterReset: "none",
279
+ margin: 0,
280
+ padding: 0,
281
+ },
282
+ "&[data-diffmap-kind='toc-card'] ol ol": {
283
+ paddingInlineStart: spacing.value(6),
284
+ },
285
+ "&[data-diffmap-kind='toc-card'] ol > li::before": {
286
+ content: "none",
287
+ },
288
+ "&[data-diffmap-kind='toc-card'] a": {
289
+ fontWeight: 400,
290
+ textDecoration: "none",
291
+ },
292
+ } as const;
293
+
153
294
  const styles = {
154
- nav: style(spacing.padding({ left: 16, right: 6, top: 8, bottom: 8 }), {
155
- boxSizing: "border-box",
295
+ rail: style({
296
+ // Sit in the prose grid's left gutter (column 1). Lines share that outer
297
+ // edge and grow inward. Sticky height keeps them in view, off the
298
+ // scrollbar, and on the article when Diff is open.
156
299
  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,
176
- },
177
- "&[data-diffmap-kind='toc'] ol ol": {
178
- paddingInlineStart: spacing.value(6),
300
+ gridRow: "1",
301
+ position: "sticky",
302
+ top: 0,
303
+ alignSelf: "start",
304
+ justifySelf: "stretch",
305
+ boxSizing: "border-box",
306
+ width: "100%",
307
+ minWidth: 0,
308
+ height: "100cqh",
309
+ zIndex: 3,
310
+ display: "flex",
311
+ flexDirection: "column",
312
+ justifyContent: "center",
313
+ alignItems: "flex-start",
314
+ pointerEvents: "none",
315
+ }),
316
+ cluster: style(spacing.padding({ top: 4, bottom: 4, left: 2, right: 6 }), {
317
+ position: "relative",
318
+ display: "flex",
319
+ flexDirection: "column",
320
+ alignItems: "flex-start",
321
+ gap: spacing.value(6),
322
+ pointerEvents: "auto",
323
+ "&[data-open='true'] [data-diffmap-kind='toc-line']": {
324
+ opacity: 0,
179
325
  },
180
- "&[data-diffmap-kind='toc'] ol > li::before": {
181
- content: "none",
326
+ }),
327
+ line: style({
328
+ display: "block",
329
+ height: "2px",
330
+ flexShrink: 0,
331
+ borderRadius: "999px",
332
+ backgroundColor: colors.gray[8],
333
+ opacity: 1,
334
+ transition: `opacity ${String(TOC_TRANSITION_MS)}ms ${motionEasing}`,
335
+ "@media (prefers-reduced-motion: reduce)": {
336
+ transition: "none",
182
337
  },
183
- "&[data-diffmap-kind='toc'] a": {
184
- fontWeight: 400,
185
- textDecoration: "none",
338
+ "&[data-current='true']": {
339
+ backgroundColor: colors.gray[12],
186
340
  },
187
341
  }),
342
+ card: style(
343
+ radius.lg,
344
+ shadow.medium,
345
+ background.element,
346
+ spacing.padding({ x: 3, y: 3 }),
347
+ {
348
+ position: "absolute",
349
+ insetInlineStart: 0,
350
+ top: "50%",
351
+ zIndex: 1,
352
+ boxSizing: "border-box",
353
+ width: "min(240px, calc(100cqw - 24px))",
354
+ maxHeight: "min(32rem, calc(100cqh - 24px))",
355
+ overflowX: "hidden",
356
+ overflowY: "auto",
357
+ overscrollBehavior: "contain",
358
+ transformOrigin: "left center",
359
+ opacity: 0,
360
+ pointerEvents: "none",
361
+ transform: "translateY(-50%) translateX(-6px) scale(0.98)",
362
+ transition: TOC_MOTION,
363
+ "@media (prefers-reduced-motion: reduce)": {
364
+ transition: "none",
365
+ },
366
+ "&[data-open='true']": {
367
+ opacity: 1,
368
+ pointerEvents: "auto",
369
+ transform: "translateY(-50%) translateX(0px) scale(1)",
370
+ },
371
+ ...tocListRules,
372
+ },
373
+ ),
188
374
  list: style(text({ size: "sm", fontWeight: 400, color: "lowContrast" })),
189
375
  item: style({
190
376
  "&::before": {
@@ -206,6 +392,10 @@ const styles = {
206
392
  "&[aria-current='location']": {
207
393
  color: colors.accent[9],
208
394
  fontWeight: 500,
395
+ backgroundColor: colors.accent[3],
396
+ },
397
+ "&[aria-current='location']:hover": {
398
+ backgroundColor: colors.accent[4],
209
399
  },
210
400
  },
211
401
  ),
@@ -0,0 +1,27 @@
1
+ import { Button, UnorderedList, backgroundColor } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ export function TocButton(props: { expanded: boolean; onClick?: () => void }) {
5
+ const className = useStyles(pressedClass);
6
+ return (
7
+ <Button
8
+ id="diffmap-toc-button"
9
+ className={className}
10
+ variant="quiet"
11
+ aria-label="Table of contents"
12
+ aria-pressed={props.expanded}
13
+ aria-expanded={props.expanded}
14
+ aria-controls="diffmap-toc"
15
+ onClick={props.onClick}
16
+ >
17
+ <UnorderedList size="sm" />
18
+ </Button>
19
+ );
20
+ }
21
+
22
+ const pressedClass = style({
23
+ flexShrink: 0,
24
+ "&[aria-pressed='true']": {
25
+ backgroundColor: backgroundColor.elementActive,
26
+ },
27
+ });