@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.
@@ -1,10 +1,18 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
2
8
  import {
3
9
  backgroundColor,
4
10
  border,
5
11
  colors,
6
12
  flex,
7
13
  flexItem,
14
+ H1,
15
+ P,
8
16
  proseHtml,
9
17
  proseMaxWidth,
10
18
  spacing,
@@ -14,9 +22,15 @@ import { style, useStyles } from "purse-styles";
14
22
  import type { ViewerDocument } from "../parseViewer.js";
15
23
  import { ComarkView } from "./ComarkView.tsx";
16
24
  import { SourceDiffPanel, type SourceSelection } from "./SourceDiffPanel.js";
17
- import { DoneButton } from "./DoneButton.tsx";
25
+ import { CloseServerButton } from "./CloseServerButton.tsx";
18
26
  import { DiffButton } from "./DiffButton.tsx";
19
- import { TableOfContents } from "./TableOfContents.tsx";
27
+ import { TocButton } from "./TocButton.tsx";
28
+ import {
29
+ hasTableOfContents,
30
+ TableOfContents,
31
+ TOC_OPEN_MIN_WIDTH_PX,
32
+ TOC_SIDE_MARGIN_PX,
33
+ } from "./TableOfContents.tsx";
20
34
  import { ViewerModeContext, type ViewerMode } from "./viewerMode.ts";
21
35
 
22
36
  type ViewerMeta = {
@@ -32,9 +46,7 @@ export function ViewerApp(props: {
32
46
  const viewerDocument = props.document;
33
47
  const meta = useViewerMeta(props.mode === "local");
34
48
  const [selection, setSelection] = useState<SourceSelection>();
35
- const [showDiffPanel, setShowDiffPanel] = useState(
36
- viewerDocument.sourceDiffs.length > 0,
37
- );
49
+ const [showDiffPanel, setShowDiffPanel] = useState(false);
38
50
  const hasSourceDiffs =
39
51
  viewerDocument.sourceDiffs.length > 0 || viewerDocument.hasReferences;
40
52
  const diffPanelOpen = hasSourceDiffs && showDiffPanel;
@@ -55,7 +67,64 @@ export function ViewerApp(props: {
55
67
  const prose = useStyles(styles.prose);
56
68
  const content = useStyles(proseHtml("md"), styles.content);
57
69
  const closed = useStyles(styles.closed);
70
+ const closedCopy = useStyles(styles.closedCopy);
71
+ const stage = useStyles(styles.stage);
58
72
  const articleRef = useRef<HTMLElement>(null);
73
+ const hasToc = hasTableOfContents(viewerDocument.headings);
74
+ const tocDismissed = useRef(false);
75
+ const tocPinned = useRef(false);
76
+ const tocFits = useRef(window.innerWidth >= TOC_OPEN_MIN_WIDTH_PX);
77
+ const remeasureToc = useRef<() => void>(() => {});
78
+ const [tocOpen, setTocOpen] = useState<TocMode | undefined>(() =>
79
+ hasToc && window.innerWidth >= TOC_OPEN_MIN_WIDTH_PX ? "space" : undefined,
80
+ );
81
+ const tocExpanded = tocOpen !== undefined;
82
+ const onDwellOpen = useCallback(() => {
83
+ setTocOpen((mode) =>
84
+ mode === "click" || mode === "space" ? mode : "dwell",
85
+ );
86
+ }, []);
87
+ const onDwellClose = useCallback(() => {
88
+ setTocOpen((mode) => {
89
+ if (mode === "click" || mode === "space") return mode;
90
+ if (tocFits.current && !tocDismissed.current) {
91
+ return tocPinned.current ? "click" : "space";
92
+ }
93
+ return undefined;
94
+ });
95
+ }, []);
96
+
97
+ useEffect(() => {
98
+ if (!hasToc) return;
99
+ const measure = () => {
100
+ const node = articleRef.current;
101
+ if (node !== null && node.clientWidth === 0) return;
102
+ const width = node?.clientWidth ?? window.innerWidth;
103
+ const next = width >= TOC_OPEN_MIN_WIDTH_PX;
104
+ const previous = tocFits.current;
105
+ tocFits.current = next;
106
+ if (previous === next) return;
107
+ if (!next) {
108
+ setTocOpen(undefined);
109
+ return;
110
+ }
111
+ setTocOpen((mode) => {
112
+ if (mode === "click" || mode === "dwell") return mode;
113
+ if (tocDismissed.current) return undefined;
114
+ return tocPinned.current ? "click" : "space";
115
+ });
116
+ };
117
+ remeasureToc.current = measure;
118
+ const observer = new ResizeObserver(measure);
119
+ const articleNode = articleRef.current;
120
+ if (articleNode) observer.observe(articleNode);
121
+ window.addEventListener("resize", measure);
122
+ return () => {
123
+ observer.disconnect();
124
+ window.removeEventListener("resize", measure);
125
+ remeasureToc.current = () => {};
126
+ };
127
+ }, [hasToc]);
59
128
 
60
129
  useEffect(() => {
61
130
  document.title = title;
@@ -68,7 +137,14 @@ export function ViewerApp(props: {
68
137
  }, []);
69
138
 
70
139
  if (shutDown) {
71
- return <main className={closed}>Closed.</main>;
140
+ return (
141
+ <main className={closed}>
142
+ <div className={closedCopy}>
143
+ <H1>Closed spec</H1>
144
+ <P>The local server stopped.</P>
145
+ </div>
146
+ </main>
147
+ );
72
148
  }
73
149
 
74
150
  return (
@@ -76,18 +152,37 @@ export function ViewerApp(props: {
76
152
  <div className={shell}>
77
153
  <header className={header}>
78
154
  <div className={heading}>
155
+ {hasToc && (
156
+ <TocButton
157
+ expanded={tocExpanded}
158
+ onClick={() => {
159
+ if (tocExpanded) {
160
+ tocDismissed.current = true;
161
+ tocPinned.current = false;
162
+ setTocOpen(undefined);
163
+ return;
164
+ }
165
+ tocDismissed.current = false;
166
+ tocPinned.current = true;
167
+ setTocOpen("click");
168
+ }}
169
+ />
170
+ )}
79
171
  <div className={titleClass}>{title}</div>
80
172
  </div>
81
173
  <div className={actions}>
82
174
  {hasSourceDiffs && (
83
175
  <DiffButton
84
176
  pressed={showDiffPanel}
85
- onClick={() => setShowDiffPanel((open) => !open)}
177
+ onClick={() => {
178
+ setShowDiffPanel((open) => !open);
179
+ window.requestAnimationFrame(() => remeasureToc.current());
180
+ }}
86
181
  />
87
182
  )}
88
183
  {props.headerActions}
89
184
  {props.mode === "local" && (
90
- <DoneButton
185
+ <CloseServerButton
91
186
  onClick={() => {
92
187
  setShutDown(true);
93
188
  // oxlint-disable-next-line typescript/no-floating-promises -- React click callbacks cannot await the server shutdown request.
@@ -97,41 +192,53 @@ export function ViewerApp(props: {
97
192
  )}
98
193
  </div>
99
194
  </header>
100
- <div className={body} data-has-source-diffs={diffPanelOpen}>
101
- <TableOfContents
102
- headings={viewerDocument.headings}
103
- articleRef={articleRef}
104
- />
105
- <article ref={articleRef} className={article}>
106
- <div className={prose}>
107
- <div className={content} data-diffmap-kind="page">
108
- <ComarkView
109
- document={viewerDocument}
110
- selectedAnnotation={selection?.annotation}
111
- onSelectAnnotation={(line) => {
112
- setShowDiffPanel(true);
113
- setSelection({
114
- annotation: line,
115
- reference: line.references[0]!,
116
- });
117
- }}
118
- />
195
+ <div className={stage}>
196
+ <div className={body} data-has-source-diffs={diffPanelOpen}>
197
+ <article ref={articleRef} className={article}>
198
+ <div className={prose} data-has-toc={hasToc ? "true" : "false"}>
199
+ <div className={content} data-diffmap-kind="page">
200
+ <ComarkView
201
+ document={viewerDocument}
202
+ selectedAnnotation={selection?.annotation}
203
+ onSelectAnnotation={(line) => {
204
+ setShowDiffPanel(true);
205
+ setSelection({
206
+ annotation: line,
207
+ reference: line.references[0]!,
208
+ });
209
+ window.requestAnimationFrame(() =>
210
+ remeasureToc.current(),
211
+ );
212
+ }}
213
+ />
214
+ </div>
215
+ {hasToc && (
216
+ <TableOfContents
217
+ headings={viewerDocument.headings}
218
+ articleRef={articleRef}
219
+ open={tocExpanded}
220
+ onDwellOpen={onDwellOpen}
221
+ onDwellClose={onDwellClose}
222
+ />
223
+ )}
119
224
  </div>
120
- </div>
121
- </article>
122
- {diffPanelOpen && (
123
- <SourceDiffPanel
124
- items={viewerDocument.sourceDiffs}
125
- selection={selection}
126
- onSelect={setSelection}
127
- />
128
- )}
225
+ </article>
226
+ {diffPanelOpen && (
227
+ <SourceDiffPanel
228
+ items={viewerDocument.sourceDiffs}
229
+ selection={selection}
230
+ onSelect={setSelection}
231
+ />
232
+ )}
233
+ </div>
129
234
  </div>
130
235
  </div>
131
236
  </ViewerModeContext.Provider>
132
237
  );
133
238
  }
134
239
 
240
+ type TocMode = "click" | "dwell" | "space";
241
+
135
242
  function useViewerMeta(enabled: boolean) {
136
243
  const [meta, setMeta] = useState<ViewerMeta>();
137
244
  useEffect(() => {
@@ -161,7 +268,7 @@ const styles = {
161
268
  backgroundColor: colors.gray[4],
162
269
  }),
163
270
  header: style(
164
- flex({ direction: "row", align: "center", justify: "between" }),
271
+ flex({ direction: "row", alignItems: "center", justifyContent: "between" }),
165
272
  spacing.padding({ x: 6, y: 3 }),
166
273
  flexItem({ size: "hug" }),
167
274
  border(["bottom"], "border"),
@@ -170,8 +277,9 @@ const styles = {
170
277
  backgroundColor: backgroundColor.app,
171
278
  },
172
279
  ),
173
- heading: style(flex({ direction: "column" }), {
280
+ heading: style(flex({ direction: "row", alignItems: "center", gap: 3 }), {
174
281
  minWidth: 0,
282
+ flex: "1 1 auto",
175
283
  }),
176
284
  title: style(text({ size: "md", fontWeight: 600, color: "highContrast" }), {
177
285
  minWidth: 0,
@@ -179,38 +287,46 @@ const styles = {
179
287
  textOverflow: "ellipsis",
180
288
  whiteSpace: "nowrap",
181
289
  }),
182
- actions: style(flex({ direction: "row", align: "center", gap: 3 }), {
290
+ actions: style(flex({ direction: "row", alignItems: "center", gap: 3 }), {
183
291
  flexShrink: 0,
184
292
  }),
293
+ stage: style(flex({ direction: "column" }), {
294
+ position: "relative",
295
+ flex: "1 1 auto",
296
+ minWidth: 0,
297
+ minHeight: 0,
298
+ }),
185
299
  body: style({
186
300
  display: "grid",
187
- gridTemplateColumns: "var(--diffmap-columns)",
188
301
  gridTemplateRows: "minmax(0, 1fr)",
189
- "--diffmap-columns": "minmax(0, max-content) minmax(0, 1fr)",
190
302
  flex: "1 1 auto",
191
303
  minHeight: 0,
192
304
  minWidth: 0,
193
305
  overflow: "hidden",
194
306
  backgroundColor: backgroundColor.app,
307
+ "--diffmap-columns": "minmax(0, 1fr)",
308
+ "--diffmap-areas": '"article"',
309
+ gridTemplateColumns: "var(--diffmap-columns)",
310
+ gridTemplateAreas: "var(--diffmap-areas)",
195
311
  "&[data-has-source-diffs='true']": {
196
- "--diffmap-columns":
197
- "minmax(0, max-content) minmax(0, 1fr) minmax(0, 1fr)",
312
+ "--diffmap-columns": "minmax(0, 1fr) minmax(0, 1fr)",
313
+ "--diffmap-areas": '"article diff"',
198
314
  },
199
315
  "@media (max-width: 900px)": {
200
316
  gridTemplateColumns: "minmax(0, 1fr)",
201
317
  gridTemplateRows: "minmax(0, 1fr) auto",
318
+ gridTemplateAreas: '"article" "diff"',
202
319
  },
203
320
  }),
204
321
  article: style(spacing.padding({ x: 12, y: 12 }), {
205
- gridColumn: "2",
322
+ gridArea: "article",
206
323
  flex: "1 1 auto",
207
324
  minWidth: 0,
208
325
  minHeight: 0,
209
326
  overflowY: "auto",
210
327
  backgroundColor: backgroundColor.app,
211
- "@media (max-width: 900px)": {
212
- gridColumn: "1",
213
- },
328
+ // Scrollport size for the sticky gutter rail (`100cqh`).
329
+ containerType: "size",
214
330
  }),
215
331
  prose: style({
216
332
  display: "grid",
@@ -218,9 +334,13 @@ const styles = {
218
334
  width: "100%",
219
335
  maxWidth: "none",
220
336
  minWidth: 0,
337
+ "&[data-has-toc='true']": {
338
+ gridTemplateColumns: `minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr) minmax(0, ${proseMaxWidth}) minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr)`,
339
+ },
221
340
  }),
222
341
  content: style({
223
342
  gridColumn: "2 / 3",
343
+ gridRow: "1",
224
344
  width: "100%",
225
345
  maxWidth: "none",
226
346
  minWidth: 0,
@@ -250,11 +370,23 @@ const styles = {
250
370
  },
251
371
  }),
252
372
  closed: style(
253
- text({ size: "md", fontWeight: 500, color: "highContrast" }),
254
- spacing.padding({ all: 12 }),
373
+ flex({
374
+ direction: "column",
375
+ alignItems: "center",
376
+ justifyContent: "center",
377
+ }),
378
+ spacing.padding({ x: 12, y: 12 }),
255
379
  {
256
380
  minHeight: "100vh",
257
381
  backgroundColor: backgroundColor.app,
258
382
  },
259
383
  ),
384
+ closedCopy: style(
385
+ flex({ direction: "column", alignItems: "center", gap: 3 }),
386
+ {
387
+ width: "100%",
388
+ maxWidth: proseMaxWidth,
389
+ textAlign: "center",
390
+ },
391
+ ),
260
392
  };
@@ -1,10 +1,11 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import type { CodeViewDiffItem, CodeViewLineSelection } from "@pierre/diffs";
3
3
  import type { SourceReference } from "../annotations.js";
4
4
  import type { SourceDefinition, DefinitionResponse } from "../definitions.js";
5
5
  import { DiffmapDefinitionError } from "../errors.js";
6
6
  import { useGitHubPin } from "../github/pin.ts";
7
7
  import { fetchPinnedBlob, findSymbolLineRange } from "../github/fetchRepo.ts";
8
+ import { useHostFiles } from "../host/files.ts";
8
9
  import { useViewerMode } from "./viewerMode.ts";
9
10
 
10
11
  export async function requestSource(
@@ -46,10 +47,15 @@ export function useSourceReference(reference: SourceReference | undefined) {
46
47
  result: SourceDefinition | Error;
47
48
  }>();
48
49
  const pin = useGitHubPin();
50
+ const hostFiles = useHostFiles();
49
51
  const mode = useViewerMode();
52
+ const hostResult = useMemo(() => {
53
+ if (reference?.kind !== "file" || hostFiles === undefined) return undefined;
54
+ return readHostFileReference(hostFiles, reference);
55
+ }, [reference, hostFiles]);
50
56
  useEffect(() => {
51
57
  if (reference?.kind !== "file") return;
52
- if (mode === "gist") return;
58
+ if (hostFiles !== undefined) return;
53
59
  let active = true;
54
60
  if (pin !== undefined) {
55
61
  // Pinned GitHub spec: load this path at the spec commit SHA, never main.
@@ -89,6 +95,7 @@ export function useSourceReference(reference: SourceReference | undefined) {
89
95
  active = false;
90
96
  };
91
97
  }
98
+ if (mode !== "local") return;
92
99
  const params = new URLSearchParams({ path: reference.path });
93
100
  if (reference.symbol !== undefined) params.set("symbol", reference.symbol);
94
101
  if (reference.start !== undefined)
@@ -101,10 +108,37 @@ export function useSourceReference(reference: SourceReference | undefined) {
101
108
  return () => {
102
109
  active = false;
103
110
  };
104
- }, [reference, pin, mode]);
111
+ }, [reference, pin, mode, hostFiles]);
112
+ if (hostResult !== undefined) return hostResult;
105
113
  return resolution?.reference === reference ? resolution?.result : undefined;
106
114
  }
107
115
 
116
+ function readHostFileReference(
117
+ files: Record<string, string>,
118
+ reference: Extract<SourceReference, { kind: "file" }>,
119
+ ): SourceDefinition | Error {
120
+ const contents = files[reference.path];
121
+ if (contents === undefined) {
122
+ return new DiffmapDefinitionError({
123
+ reason: `No hosted file for ${reference.path}.`,
124
+ });
125
+ }
126
+ const lineCount = Math.max(1, contents.split(/\r?\n/).length);
127
+ const range =
128
+ reference.symbol !== undefined
129
+ ? findSymbolLineRange(contents, reference.symbol)
130
+ : {
131
+ start: reference.start ?? 1,
132
+ end: reference.end ?? lineCount,
133
+ };
134
+ return {
135
+ path: reference.path,
136
+ contents,
137
+ start: range.start,
138
+ end: range.end,
139
+ };
140
+ }
141
+
108
142
  export function matchingSourceDiff(
109
143
  source: SourceDefinition,
110
144
  items: CodeViewDiffItem[],
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext } from "react";
2
2
 
3
- export type ViewerMode = "local" | "gist" | "github";
3
+ export type ViewerMode = "local" | "gist" | "github" | "host";
4
4
 
5
5
  export const ViewerModeContext = createContext<ViewerMode>("local");
6
6
 
package/vite.config.ts CHANGED
@@ -34,7 +34,7 @@ export default defineConfig(({ command }) => {
34
34
  },
35
35
  clearScreen: false,
36
36
  server: {
37
- port: 4177,
37
+ port: 0,
38
38
  strictPort: false,
39
39
  },
40
40
  };
@@ -1,24 +0,0 @@
1
- import type { SVGProps } from "react";
2
-
3
- export function Check(props: SVGProps<SVGSVGElement>) {
4
- return (
5
- <svg
6
- focusable="false"
7
- aria-hidden="true"
8
- role="img"
9
- width={24}
10
- height={24}
11
- fill="none"
12
- viewBox="0 0 24 24"
13
- {...props}
14
- >
15
- <path
16
- stroke="currentColor"
17
- strokeLinecap="round"
18
- strokeLinejoin="round"
19
- strokeWidth={1.5}
20
- d="M5.75 12.5L10 16.75L18.25 7.25"
21
- />
22
- </svg>
23
- );
24
- }
@@ -1,32 +0,0 @@
1
- import { Button, background, colors, iconSizeValues, text } from "maui";
2
- import { style, useStyles } from "purse-styles";
3
- import { Check } from "./Check.tsx";
4
-
5
- const checkIcon = style({
6
- width: iconSizeValues.sm,
7
- height: iconSizeValues.sm,
8
- });
9
-
10
- export function DoneButton(props: { onClick?: () => void }) {
11
- const checkClass = useStyles(checkIcon);
12
- const primaryClass = useStyles(primaryButtonClass);
13
- return (
14
- <Button className={primaryClass} onClick={props.onClick}>
15
- <Check className={checkClass} />
16
- Done
17
- </Button>
18
- );
19
- }
20
-
21
- const primaryButtonClass = style(
22
- background.accent,
23
- text({ size: "xs", fontWeight: 400, color: "onAccent" }),
24
- {
25
- "&:hover": {
26
- backgroundColor: colors.accent[10],
27
- },
28
- "&:active": {
29
- backgroundColor: colors.accent[11],
30
- },
31
- },
32
- );