@tanishqkancharla/diffmap 0.1.3

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin.js +5 -0
  4. package/index.html +38 -0
  5. package/package.json +71 -0
  6. package/src/annotations.ts +82 -0
  7. package/src/cli.ts +94 -0
  8. package/src/codeFont.ts +2 -0
  9. package/src/contentPlugin.ts +47 -0
  10. package/src/definitions.ts +320 -0
  11. package/src/diagramAnnotations.ts +34 -0
  12. package/src/errors.ts +41 -0
  13. package/src/extractDocument.ts +20 -0
  14. package/src/gist/GistApp.tsx +292 -0
  15. package/src/gist/GistStatus.tsx +137 -0
  16. package/src/gist/fetchGist.ts +128 -0
  17. package/src/gist/main.tsx +25 -0
  18. package/src/gist/route.test.ts +90 -0
  19. package/src/gist/route.ts +134 -0
  20. package/src/index.ts +22 -0
  21. package/src/invokeCwd.ts +10 -0
  22. package/src/mermaid.ts +29 -0
  23. package/src/normalizeDiff.ts +39 -0
  24. package/src/parseFence.ts +91 -0
  25. package/src/parseViewer.ts +399 -0
  26. package/src/registry.ts +121 -0
  27. package/src/serve.ts +350 -0
  28. package/src/share.ts +94 -0
  29. package/src/viewer/CallStackDiff.tsx +156 -0
  30. package/src/viewer/Check.tsx +24 -0
  31. package/src/viewer/CodeDiff.tsx +26 -0
  32. package/src/viewer/ComarkView.tsx +209 -0
  33. package/src/viewer/DiffButton.tsx +24 -0
  34. package/src/viewer/DoneButton.tsx +32 -0
  35. package/src/viewer/Fence.tsx +51 -0
  36. package/src/viewer/FileExcerpt.tsx +65 -0
  37. package/src/viewer/HtmlBlock.tsx +19 -0
  38. package/src/viewer/HtmlPlaceholder.tsx +29 -0
  39. package/src/viewer/MermaidBlock.tsx +126 -0
  40. package/src/viewer/SourceDiffPanel.tsx +305 -0
  41. package/src/viewer/TableOfContents.tsx +211 -0
  42. package/src/viewer/ViewerApp.tsx +257 -0
  43. package/src/viewer/diagramLinks.ts +74 -0
  44. package/src/viewer/diffsTheme.ts +4 -0
  45. package/src/viewer/main.tsx +17 -0
  46. package/src/viewer/pierre.ts +47 -0
  47. package/src/viewer/sourceNavigation.ts +104 -0
  48. package/src/viewer/styles.css +9 -0
  49. package/src/viewer/viewerMode.ts +9 -0
  50. package/src/viewer/vite-env.d.ts +4 -0
  51. package/vite.config.ts +41 -0
@@ -0,0 +1,305 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { CodeView, type CodeViewHandle } from "@pierre/diffs/react";
3
+ import type {
4
+ CodeViewDiffItem,
5
+ CodeViewOptions,
6
+ CodeViewLineSelection,
7
+ } from "@pierre/diffs";
8
+ import {
9
+ backgroundColor,
10
+ border,
11
+ borderColor,
12
+ Button,
13
+ flex,
14
+ spacing,
15
+ text,
16
+ useTheme,
17
+ } from "maui";
18
+ import { style, useStyles } from "purse-styles";
19
+ import {
20
+ referenceLabel,
21
+ type SourceAnnotation,
22
+ type SourceReference,
23
+ } from "../annotations.js";
24
+ import type { SourceDefinition } from "../definitions.js";
25
+ import {
26
+ matchingSourceDiff,
27
+ requestSource,
28
+ useSourceReference,
29
+ } from "./sourceNavigation.js";
30
+ import { pierreDiffOptions, sourceSelectionCss } from "./pierre.js";
31
+ import { useViewerMode } from "./viewerMode.ts";
32
+
33
+ export type SourceSelection = {
34
+ annotation: SourceAnnotation;
35
+ reference: SourceReference;
36
+ };
37
+
38
+ type DefinitionNavigation = {
39
+ selection: SourceSelection | undefined;
40
+ history: SourceDefinition[];
41
+ status: string | undefined;
42
+ };
43
+
44
+ export function SourceDiffPanel(props: {
45
+ items: CodeViewDiffItem[];
46
+ selection: SourceSelection | undefined;
47
+ onSelect: (selection: SourceSelection) => void;
48
+ }) {
49
+ const { resolvedTheme } = useTheme();
50
+ const mode = useViewerMode();
51
+ const viewer = useRef<CodeViewHandle<undefined, undefined>>(null);
52
+ const [navigation, setNavigation] = useState<DefinitionNavigation>({
53
+ selection: props.selection,
54
+ history: [],
55
+ status: undefined,
56
+ });
57
+ if (navigation.selection !== props.selection) {
58
+ setNavigation({
59
+ selection: props.selection,
60
+ history: [],
61
+ status: undefined,
62
+ });
63
+ }
64
+ const { history } = navigation;
65
+ const request = useRef(0);
66
+ const definition = history.at(-1);
67
+ const panel = useStyles(styles.panel);
68
+ const header = useStyles(styles.header);
69
+ const links = useStyles(styles.links);
70
+ const code = useStyles(styles.code);
71
+ const options = pierreDiffOptions({
72
+ themeType: resolvedTheme,
73
+ disableFileHeader: false,
74
+ });
75
+ const selection = props.selection;
76
+ const source = useSourceReference(selection?.reference);
77
+ const resolvedFile = source instanceof Error ? undefined : source;
78
+ const linkedDiff = useMemo(
79
+ () =>
80
+ resolvedFile === undefined
81
+ ? undefined
82
+ : matchingSourceDiff(resolvedFile, props.items),
83
+ [resolvedFile, props.items],
84
+ );
85
+ const file =
86
+ definition === undefined
87
+ ? linkedDiff === undefined
88
+ ? resolvedFile
89
+ : undefined
90
+ : definition;
91
+ const status =
92
+ navigation.status !== undefined
93
+ ? navigation.status
94
+ : source instanceof Error
95
+ ? source.message
96
+ : selection?.reference.kind === "file" && source === undefined
97
+ ? "Opening source…"
98
+ : undefined;
99
+ const selectedLines = useMemo<CodeViewLineSelection | null>(() => {
100
+ if (file !== undefined)
101
+ return { id: file.path, range: { start: file.start, end: file.end } };
102
+ if (linkedDiff !== undefined) return linkedDiff;
103
+ const ref = selection?.reference;
104
+ // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a controlled empty selection.
105
+ if (ref === undefined || ref.kind === "file") return null;
106
+ return {
107
+ id: ref.id,
108
+ range: {
109
+ start: ref.start,
110
+ end: ref.end,
111
+ side: ref.side === "old" ? "deletions" : "additions",
112
+ },
113
+ };
114
+ }, [selection, file, linkedDiff]);
115
+
116
+ useEffect(() => {
117
+ if (selectedLines === null) return;
118
+ viewer.current?.scrollTo({
119
+ type: "range",
120
+ ...selectedLines,
121
+ align: "center",
122
+ behavior: "instant",
123
+ });
124
+ }, [selectedLines]);
125
+
126
+ const onTokenClick: CodeViewOptions<undefined, undefined>["onTokenClick"] = (
127
+ token,
128
+ event,
129
+ context,
130
+ ) => {
131
+ if (mode === "gist") return;
132
+ if (!event.metaKey && !event.ctrlKey) return;
133
+ event.preventDefault();
134
+ const item = context.item;
135
+ let filePath: string;
136
+ let lineText: string;
137
+ if (item.type === "file") {
138
+ filePath = item.file.name;
139
+ lineText = item.file.contents.split(/\r?\n/)[token.lineNumber - 1]!;
140
+ } else {
141
+ const side =
142
+ "side" in token && token.side === "deletions" ? "deletion" : "addition";
143
+ const diff = item.fileDiff;
144
+ filePath = diff.name;
145
+ const hunk = diff.hunks.find(
146
+ (h) =>
147
+ token.lineNumber >= h[`${side}Start`] &&
148
+ token.lineNumber < h[`${side}Start`] + h[`${side}Count`],
149
+ )!;
150
+ const index = diff.isPartial
151
+ ? hunk[`${side}LineIndex`] + token.lineNumber - hunk[`${side}Start`]
152
+ : token.lineNumber - 1;
153
+ lineText = diff[`${side}Lines`][index]!.replace(/\r?\n$/, "");
154
+ }
155
+ const params = new URLSearchParams({
156
+ path: filePath,
157
+ line: String(token.lineNumber),
158
+ column: String(token.lineCharStart),
159
+ text: lineText,
160
+ });
161
+ const currentRequest = ++request.current;
162
+ setNavigation({ selection, history, status: "Finding definition…" });
163
+ // oxlint-disable-next-line typescript/no-floating-promises -- Token callbacks cannot await; the request owns its status update.
164
+ void requestSource("definition", params).then((result) => {
165
+ if (currentRequest !== request.current) return;
166
+ setNavigation((current) => {
167
+ if (current.selection !== selection) return current;
168
+ if (result instanceof Error)
169
+ return { ...current, status: result.message };
170
+ return {
171
+ selection,
172
+ history: [...history, result],
173
+ status: undefined,
174
+ };
175
+ });
176
+ });
177
+ };
178
+
179
+ return (
180
+ <aside id="source-diff-panel" aria-label="Source changes" className={panel}>
181
+ <div className={header}>
182
+ <strong>
183
+ {definition !== undefined
184
+ ? "Symbol definition"
185
+ : file !== undefined
186
+ ? "Source file"
187
+ : "Source changes"}
188
+ </strong>
189
+ {definition !== undefined && (
190
+ <Button
191
+ variant="quiet"
192
+ onClick={() => {
193
+ request.current++;
194
+ setNavigation({
195
+ selection,
196
+ history: history.slice(0, -1),
197
+ status: undefined,
198
+ });
199
+ }}
200
+ >
201
+ {history.length === 1 ? "Back to reference" : "Back"}
202
+ </Button>
203
+ )}
204
+ {mode === "local" && (
205
+ <span>⌘-click or Ctrl-click a symbol to go to its definition.</span>
206
+ )}
207
+ {status !== undefined && <span role="status">{status}</span>}
208
+ {selection === undefined ? (
209
+ <span>
210
+ Select a linked stack row or diagram element to open its source.
211
+ </span>
212
+ ) : (
213
+ <>
214
+ <span>
215
+ {selection.annotation.text.replace(/^[+ -]/, "").trim()}
216
+ </span>
217
+ {selection.annotation.references.length > 1 && (
218
+ <div className={links} aria-label="Linked changes">
219
+ {selection.annotation.references.map((reference, index) => (
220
+ <Button
221
+ key={index}
222
+ variantColor="blue"
223
+ variant={
224
+ reference === selection.reference ? "primary" : "quiet"
225
+ }
226
+ onClick={() =>
227
+ props.onSelect({
228
+ annotation: selection.annotation,
229
+ reference,
230
+ })
231
+ }
232
+ >
233
+ {referenceLabel(reference)}
234
+ </Button>
235
+ ))}
236
+ </div>
237
+ )}
238
+ </>
239
+ )}
240
+ </div>
241
+ <CodeView
242
+ ref={viewer}
243
+ className={code}
244
+ items={
245
+ file === undefined
246
+ ? props.items
247
+ : [
248
+ {
249
+ type: "file",
250
+ id: file.path,
251
+ file: {
252
+ name: file.path,
253
+ contents: file.contents,
254
+ },
255
+ },
256
+ ]
257
+ }
258
+ selectedLines={selectedLines}
259
+ disableWorkerPool
260
+ options={{
261
+ ...options,
262
+ unsafeCSS: options.unsafeCSS + sourceSelectionCss,
263
+ stickyHeaders: true,
264
+ onTokenClick,
265
+ }}
266
+ />
267
+ </aside>
268
+ );
269
+ }
270
+
271
+ const styles = {
272
+ panel: style(flex({ direction: "column" }), border(["left"], "border"), {
273
+ gridColumn: "3",
274
+ minWidth: 0,
275
+ minHeight: 0,
276
+ overflow: "hidden",
277
+ backgroundColor: backgroundColor.app,
278
+ "@media (max-width: 900px)": {
279
+ gridColumn: "1",
280
+ height: "45vh",
281
+ borderLeft: "none",
282
+ borderTop: `1px solid ${borderColor.border}`,
283
+ },
284
+ }),
285
+ header: style(
286
+ flex({ direction: "column" }),
287
+ spacing.padding({ all: 4 }),
288
+ border(["bottom"], "border"),
289
+ text({ size: "sm", color: "lowContrast" }),
290
+ {
291
+ gap: "6px",
292
+ overflowWrap: "anywhere",
293
+ maxHeight: "35%",
294
+ overflowY: "auto",
295
+ flexShrink: 0,
296
+ },
297
+ ),
298
+ links: style(flex({ direction: "row" }), { gap: "6px", flexWrap: "wrap" }),
299
+ code: style({
300
+ flex: "1 1 auto",
301
+ minWidth: 0,
302
+ minHeight: 0,
303
+ overflow: "auto",
304
+ }),
305
+ };
@@ -0,0 +1,211 @@
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import { colors, focusRing, radius, spacing, text } from "maui";
3
+ import { style, useStyles } from "purse-styles";
4
+ import type { ViewerHeading } from "../parseViewer.js";
5
+
6
+ type TocItem = ViewerHeading & { children: TocItem[] };
7
+
8
+ export function TableOfContents(props: {
9
+ headings: ViewerHeading[];
10
+ articleRef: RefObject<HTMLElement | null>;
11
+ }) {
12
+ const items = nestHeadings(tocHeadings(props.headings));
13
+ const { activeId, setActiveId } = useActiveHeading(
14
+ props.headings,
15
+ props.articleRef,
16
+ );
17
+ const navClass = useStyles(styles.nav);
18
+ const listClass = useStyles(styles.list);
19
+ if (items.length === 0) return undefined;
20
+
21
+ return (
22
+ <nav
23
+ className={navClass}
24
+ aria-label="Table of contents"
25
+ data-diffmap-kind="toc"
26
+ >
27
+ <TocList
28
+ items={items}
29
+ className={listClass}
30
+ activeId={activeId}
31
+ onSelect={setActiveId}
32
+ />
33
+ </nav>
34
+ );
35
+ }
36
+
37
+ function TocList(props: {
38
+ items: TocItem[];
39
+ className: string;
40
+ activeId: string | undefined;
41
+ onSelect: (id: string) => void;
42
+ }) {
43
+ return (
44
+ <ol className={props.className}>
45
+ {props.items.map((item) => (
46
+ <TocEntry
47
+ key={item.id}
48
+ item={item}
49
+ listClass={props.className}
50
+ activeId={props.activeId}
51
+ onSelect={props.onSelect}
52
+ />
53
+ ))}
54
+ </ol>
55
+ );
56
+ }
57
+
58
+ function TocEntry(props: {
59
+ item: TocItem;
60
+ listClass: string;
61
+ activeId: string | undefined;
62
+ onSelect: (id: string) => void;
63
+ }) {
64
+ const itemClass = useStyles(styles.item);
65
+ const linkClass = useStyles(styles.link);
66
+ const current = props.activeId === props.item.id;
67
+ return (
68
+ <li className={itemClass}>
69
+ <a
70
+ className={linkClass}
71
+ href={`#${props.item.id}`}
72
+ aria-current={current ? "location" : undefined}
73
+ onClick={(event) => {
74
+ const heading = document.getElementById(props.item.id);
75
+ if (!heading) return;
76
+ event.preventDefault();
77
+ props.onSelect(props.item.id);
78
+ heading.scrollIntoView({ behavior: "smooth", block: "start" });
79
+ history.pushState({}, "", `#${props.item.id}`);
80
+ }}
81
+ >
82
+ {props.item.text}
83
+ </a>
84
+ {props.item.children.length > 0 && (
85
+ <TocList
86
+ items={props.item.children}
87
+ className={props.listClass}
88
+ activeId={props.activeId}
89
+ onSelect={props.onSelect}
90
+ />
91
+ )}
92
+ </li>
93
+ );
94
+ }
95
+
96
+ function tocHeadings(headings: ViewerHeading[]) {
97
+ const first = headings[0];
98
+ if (first?.level === 1) return headings.slice(1);
99
+ return headings;
100
+ }
101
+
102
+ function nestHeadings(headings: ViewerHeading[]): TocItem[] {
103
+ const items: TocItem[] = [];
104
+ const stack: TocItem[] = [];
105
+ for (const heading of headings) {
106
+ const item: TocItem = { ...heading, children: [] };
107
+ while (stack.length > 0) {
108
+ const parent = stack[stack.length - 1];
109
+ if (parent === undefined || parent.level < heading.level) break;
110
+ stack.pop();
111
+ }
112
+ const parent = stack[stack.length - 1];
113
+ if (parent === undefined) items.push(item);
114
+ else parent.children.push(item);
115
+ stack.push(item);
116
+ }
117
+ return items;
118
+ }
119
+
120
+ function useActiveHeading(
121
+ headings: ViewerHeading[],
122
+ articleRef: RefObject<HTMLElement | null>,
123
+ ) {
124
+ const [activeId, setActiveId] = useState<string>();
125
+
126
+ useEffect(() => {
127
+ const ids = tocHeadings(headings).map((heading) => heading.id);
128
+ const nodes = ids.flatMap((id) => {
129
+ const node = document.getElementById(id);
130
+ return node ? [node] : [];
131
+ });
132
+ if (nodes.length === 0) return;
133
+ const root = articleRef.current ?? undefined;
134
+ const visible = new Set<string>();
135
+ const observer = new IntersectionObserver(
136
+ (entries) => {
137
+ for (const entry of entries) {
138
+ if (entry.isIntersecting) visible.add(entry.target.id);
139
+ else visible.delete(entry.target.id);
140
+ }
141
+ const next = ids.find((id) => visible.has(id));
142
+ if (next !== undefined) setActiveId(next);
143
+ },
144
+ { root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
145
+ );
146
+ for (const node of nodes) observer.observe(node);
147
+ return () => observer.disconnect();
148
+ }, [articleRef, headings]);
149
+
150
+ return { activeId, setActiveId };
151
+ }
152
+
153
+ const styles = {
154
+ nav: style(spacing.padding({ left: 16, right: 6, top: 8, bottom: 8 }), {
155
+ boxSizing: "border-box",
156
+ 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
+ "@media (max-width: 1100px)": {
168
+ display: "none",
169
+ },
170
+ "&[data-diffmap-kind='toc'] ol": {
171
+ listStyle: "none",
172
+ counterReset: "none",
173
+ margin: 0,
174
+ padding: 0,
175
+ },
176
+ "&[data-diffmap-kind='toc'] ol ol": {
177
+ paddingInlineStart: spacing.value(6),
178
+ },
179
+ "&[data-diffmap-kind='toc'] ol > li::before": {
180
+ content: "none",
181
+ },
182
+ "&[data-diffmap-kind='toc'] a": {
183
+ fontWeight: 400,
184
+ textDecoration: "none",
185
+ },
186
+ }),
187
+ list: style(text({ size: "sm", fontWeight: 400, color: "lowContrast" })),
188
+ item: style({
189
+ "&::before": {
190
+ content: "none",
191
+ },
192
+ }),
193
+ link: style(
194
+ radius.sm,
195
+ spacing.padding({ x: 4, y: 2 }),
196
+ text({ size: "sm", fontWeight: 400, color: "lowContrast" }),
197
+ focusRing(),
198
+ {
199
+ display: "block",
200
+ overflowWrap: "anywhere",
201
+ cursor: "pointer",
202
+ "&:hover": {
203
+ backgroundColor: colors.gray[3],
204
+ },
205
+ "&[aria-current='location']": {
206
+ color: colors.accent[9],
207
+ fontWeight: 500,
208
+ },
209
+ },
210
+ ),
211
+ };