@tanishqkancharla/diffmap 0.1.5 → 0.3.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 (42) hide show
  1. package/README.md +64 -57
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +235 -0
  6. package/src/cli.ts +1 -0
  7. package/src/errors.ts +5 -0
  8. package/src/gist/GistApp.tsx +38 -17
  9. package/src/gist/fetchGist.ts +47 -0
  10. package/src/git.test.ts +402 -0
  11. package/src/git.ts +498 -0
  12. package/src/github/GitHubApp.tsx +39 -22
  13. package/src/github/fetchRepo.test.ts +59 -1
  14. package/src/github/fetchRepo.ts +99 -43
  15. package/src/github/patch.test.ts +18 -9
  16. package/src/github/patch.ts +30 -19
  17. package/src/host/HostHome.tsx +20 -7
  18. package/src/host/files.ts +5 -1
  19. package/src/host/home.md +17 -55
  20. package/src/host/home.test.ts +4 -3
  21. package/src/index.ts +1 -0
  22. package/src/parseFence.ts +6 -6
  23. package/src/parseViewer.test.ts +140 -0
  24. package/src/parseViewer.ts +104 -78
  25. package/src/serve.ts +34 -1
  26. package/src/share.ts +62 -1
  27. package/src/viewer/CallStackDiff.tsx +15 -3
  28. package/src/viewer/ChangesCard.tsx +267 -0
  29. package/src/viewer/ComarkView.tsx +83 -15
  30. package/src/viewer/Fence.tsx +9 -1
  31. package/src/viewer/MermaidBlock.tsx +99 -11
  32. package/src/viewer/RangePicker.tsx +526 -0
  33. package/src/viewer/SourceDiffPanel.tsx +254 -116
  34. package/src/viewer/TableOfContents.tsx +177 -93
  35. package/src/viewer/ViewerApp.tsx +34 -114
  36. package/src/viewer/changesSource.ts +91 -0
  37. package/src/viewer/sourceHeader.test.ts +2 -87
  38. package/src/viewer/sourceHeader.ts +4 -63
  39. package/src/viewer/sourceNavigation.ts +0 -43
  40. package/vite.config.ts +1 -0
  41. package/vite.gist.config.ts +57 -0
  42. package/src/viewer/TocButton.tsx +0 -27
@@ -74,6 +74,92 @@ test("parseViewerDocument accepts a spec with valid mermaid", async () => {
74
74
  );
75
75
  });
76
76
 
77
+ test("parseViewerDocument turns prose [[path#symbol]] into a short reference", () => {
78
+ const source =
79
+ "See [[packages/server/src/agent/Thread.ts#Thread.attach]] here, not `[[path]]` or [x [[a.ts]]](https://x.dev).\n";
80
+ const document = parseViewerDocument(source);
81
+ assert.equal(document instanceof Error, false);
82
+ if (document instanceof Error) return;
83
+ assert.equal(document.references.length, 1);
84
+ const paragraph = document.nodes[0];
85
+ assert.equal(paragraph?.type, "element");
86
+ if (paragraph?.type !== "element") return;
87
+ assert.deepEqual(
88
+ paragraph.children.map((node) =>
89
+ node.type === "element"
90
+ ? { tag: node.tag, children: node.children }
91
+ : node,
92
+ ),
93
+ [
94
+ { type: "text", value: "See " },
95
+ {
96
+ type: "reference",
97
+ annotation: {
98
+ text: "Thread.ts#Thread.attach",
99
+ references: [
100
+ {
101
+ kind: "file",
102
+ path: "packages/server/src/agent/Thread.ts",
103
+ symbol: "Thread.attach",
104
+ },
105
+ ],
106
+ },
107
+ },
108
+ { type: "text", value: " here, not " },
109
+ { tag: "code", children: [{ type: "text", value: "[[path]]" }] },
110
+ { type: "text", value: " or " },
111
+ { tag: "a", children: [{ type: "text", value: "x [[a.ts]]" }] },
112
+ { type: "text", value: "." },
113
+ ],
114
+ );
115
+ assert.equal(
116
+ parseViewerDocument("See [[missing:new:1]].\n") instanceof Error,
117
+ true,
118
+ );
119
+ });
120
+
121
+ test("parseViewerDocument reads commit references and changes fences", () => {
122
+ const document = parseViewerDocument(
123
+ [
124
+ "```changes",
125
+ "a1b2c3d..e4f5a6b",
126
+ "```",
127
+ "",
128
+ "```changes",
129
+ "```",
130
+ "",
131
+ "See [[a1b2c3d:src/store.ts#L12-L30]] and [[src/store.ts#L3]].",
132
+ "",
133
+ ].join("\n"),
134
+ );
135
+ assert.equal(document instanceof Error, false);
136
+ if (document instanceof Error) return;
137
+ assert.deepEqual(document.changeRevs, ["a1b2c3d..e4f5a6b", "working"]);
138
+ assert.deepEqual(document.references, [
139
+ { kind: "file", rev: "a1b2c3d", path: "src/store.ts", start: 12, end: 30 },
140
+ { kind: "file", path: "src/store.ts", start: 3, end: 3 },
141
+ ]);
142
+ // A commit with a symbol, or a changes fence that is not a SHA, is an error.
143
+ assert.equal(
144
+ parseViewerDocument("See [[a1b2c3d:src/store.ts#save]].\n") instanceof
145
+ Error,
146
+ true,
147
+ );
148
+ assert.equal(
149
+ parseViewerDocument("```changes\nmain\n```\n") instanceof Error,
150
+ true,
151
+ );
152
+ // The removed patch fence fails instead of rendering as code.
153
+ assert.match(
154
+ String(
155
+ parseViewerDocument(
156
+ "```source-diff:store:src/store.ts\ndiff --git a/x b/x\n```\n",
157
+ ),
158
+ ),
159
+ /source-diff:store:src\/store\.ts" fences were removed/,
160
+ );
161
+ });
162
+
77
163
  test("parseViewerDocument fails on broken mermaid with file and diagram", () => {
78
164
  const source = brokenMermaidSpec();
79
165
  const parsed = parseViewerDocument(source, "broken.md");
@@ -138,6 +224,7 @@ test("startServer does not listen when mermaid is invalid", async () => {
138
224
  test("shareMarkdownFile does not call gh when mermaid is invalid", async () => {
139
225
  await withTempSpec(brokenMermaidSpec(), async (filePath) => {
140
226
  const shared = await shareMarkdownFile({
227
+ workspaceRoot: packageRoot,
141
228
  filePath,
142
229
  isPublic: false,
143
230
  });
@@ -147,6 +234,59 @@ test("shareMarkdownFile does not call gh when mermaid is invalid", async () => {
147
234
  });
148
235
  });
149
236
 
237
+ test("shareMarkdownFile uploads the files excerpts and rev-less references read", async () => {
238
+ const spec = [
239
+ "# Shared",
240
+ "",
241
+ "See [[src/share.ts#shareMarkdownFile]].",
242
+ "",
243
+ "```1:3:src/changes.ts",
244
+ "```",
245
+ "",
246
+ ].join("\n");
247
+ await withTempSpec(spec, async (filePath) => {
248
+ const dir = path.dirname(filePath);
249
+ const uploaded = path.join(dir, "uploaded");
250
+ await fs.mkdir(uploaded);
251
+ const gh = path.join(dir, "gh");
252
+ await fs.writeFile(
253
+ gh,
254
+ [
255
+ "#!/bin/sh",
256
+ 'if [ "$1" = auth ]; then exit 0; fi',
257
+ `for arg; do case "$arg" in *.json) cp "$arg" ${JSON.stringify(uploaded)};; esac; done`,
258
+ "echo https://gist.github.com/0123456789abcdef0123456789abcdef",
259
+ ].join("\n"),
260
+ { mode: 0o755 },
261
+ );
262
+ const originalPath = process.env.PATH;
263
+ process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
264
+ try {
265
+ const shared = await shareMarkdownFile({
266
+ workspaceRoot: packageRoot,
267
+ filePath,
268
+ isPublic: false,
269
+ });
270
+ assert.equal(shared instanceof Error, false, String(shared));
271
+ } finally {
272
+ process.env.PATH = originalPath;
273
+ }
274
+ const files: unknown = JSON.parse(
275
+ await fs.readFile(path.join(uploaded, "diffmap-files.json"), "utf8"),
276
+ );
277
+ assert.deepEqual(files, {
278
+ "src/share.ts": await fs.readFile(
279
+ path.join(packageRoot, "src/share.ts"),
280
+ "utf8",
281
+ ),
282
+ "src/changes.ts": await fs.readFile(
283
+ path.join(packageRoot, "src/changes.ts"),
284
+ "utf8",
285
+ ),
286
+ });
287
+ });
288
+ });
289
+
150
290
  function mermaidCliMessage(filePath: string) {
151
291
  return `diffmap could not parse mermaid in ${filePath} (diagram 1, line 3): Invalid mermaid header: "flwochart TD". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`;
152
292
  }
@@ -1,7 +1,13 @@
1
1
  import * as errore from "errore";
2
- import { parsePatchFiles, type CodeViewDiffItem } from "@pierre/diffs";
3
2
  import { parseAST } from "md4x/napi";
4
3
  import type { ComarkElement, ComarkNode } from "md4x/napi";
4
+ import {
5
+ parseReference,
6
+ referenceLabel,
7
+ type SourceAnnotation,
8
+ type SourceReference,
9
+ } from "./annotations.js";
10
+ import { isRev, type Rev } from "./changes.js";
5
11
  import { DiffmapAnnotationError, DiffmapParseError } from "./errors.js";
6
12
  import { parseMermaidDiagram } from "./mermaid.js";
7
13
  import { parseFence, type Fence } from "./parseFence.js";
@@ -9,8 +15,10 @@ import { parseFence, type Fence } from "./parseFence.js";
9
15
  export type ViewerDocument = {
10
16
  nodes: ViewerNode[];
11
17
  headings: ViewerHeading[];
12
- sourceDiffs: CodeViewDiffItem[];
13
- hasReferences: boolean;
18
+ /** Every reference in stacks, diagrams, and prose. */
19
+ references: SourceReference[];
20
+ /** Revs of `changes` fences, in document order. */
21
+ changeRevs: Rev[];
14
22
  };
15
23
 
16
24
  export type ViewerHeading = {
@@ -19,7 +27,12 @@ export type ViewerHeading = {
19
27
  id: string;
20
28
  };
21
29
 
22
- export type ViewerNode = ViewerText | ViewerElement | ViewerHtml | ViewerView;
30
+ export type ViewerNode =
31
+ | ViewerText
32
+ | ViewerElement
33
+ | ViewerHtml
34
+ | ViewerView
35
+ | ViewerReference;
23
36
 
24
37
  export type ViewerText = {
25
38
  type: "text";
@@ -44,6 +57,12 @@ export type ViewerView = {
44
57
  fence: Fence;
45
58
  };
46
59
 
60
+ /** A `[[path#symbol]]` in prose. The annotation text is the short label. */
61
+ export type ViewerReference = {
62
+ type: "reference";
63
+ annotation: SourceAnnotation;
64
+ };
65
+
47
66
  export type ViewerElementAttrs = {
48
67
  id: string | undefined;
49
68
  href: string | undefined;
@@ -64,49 +83,32 @@ export function parseViewerDocument(source: string, path = "spec") {
64
83
  catch: (cause) => new DiffmapParseError({ cause }),
65
84
  });
66
85
  if (tree instanceof Error) return tree;
67
- const nodes = tree.nodes.flatMap(fromNode);
86
+ const nodes = tree.nodes.flatMap((node) => fromNode(node));
68
87
  const fences = collectFences(nodes);
69
- const sourceDiffs: CodeViewDiffItem[] = [];
88
+ const changeRevs: Rev[] = [];
70
89
  for (const fence of fences) {
71
- if (fence.kind !== "source-diff") continue;
72
- if (sourceDiffs.some((diff) => diff.id === fence.id)) {
73
- return new DiffmapAnnotationError({
74
- reason: `Duplicate source diff ID "${fence.id}"`,
75
- });
76
- }
77
- const patches = errore.try({
78
- try: () => parsePatchFiles(fence.source, undefined, true),
79
- catch: (cause) =>
80
- new DiffmapAnnotationError({
81
- reason: `Invalid patch "${fence.id}"`,
82
- cause,
83
- }),
84
- });
85
- if (patches instanceof Error) return patches;
86
- const files = patches.flatMap((patch) => patch.files);
87
- const fileDiff = files[0];
88
- if (
89
- files.length !== 1 ||
90
- fileDiff === undefined ||
91
- fileDiff.hunks.length === 0
92
- ) {
90
+ if (fence.kind === "code" && fence.lang.startsWith("source-diff")) {
93
91
  return new DiffmapAnnotationError({
94
- reason: `Source diff "${fence.id}" must contain one file with unified diff hunks`,
92
+ reason: `"${fence.lang}" fences were removed. Commit the change, then use a changes fence with the commit SHA and link lines with [[<sha>:path#L12-L30]]`,
95
93
  });
96
94
  }
97
- if (fileDiff.name !== fence.path) {
95
+ if (fence.kind !== "changes") continue;
96
+ if (!isRev(fence.rev)) {
98
97
  return new DiffmapAnnotationError({
99
- reason: `Source diff "${fence.id}" path does not match its patch (${fileDiff.name})`,
98
+ reason: `Invalid changes "${fence.rev}". Use a commit SHA, a SHA range (a1b2c3d..e4f5a6b), or leave it empty for uncommitted changes`,
100
99
  });
101
100
  }
102
- sourceDiffs.push({ id: fence.id, type: "diff", fileDiff });
101
+ changeRevs.push(fence.rev);
103
102
  }
104
- const annotations = fences.flatMap((fence) => {
105
- if (fence.kind === "callstack") return fence.lines;
106
- if (fence.kind === "mermaid")
107
- return fence.annotations.map((link) => link.annotation);
108
- return [];
109
- });
103
+ const annotations = [
104
+ ...fences.flatMap((fence) => {
105
+ if (fence.kind === "callstack") return fence.lines;
106
+ if (fence.kind === "mermaid")
107
+ return fence.annotations.map((link) => link.annotation);
108
+ return [];
109
+ }),
110
+ ...collectReferences(nodes),
111
+ ];
110
112
  const mermaidFences = fences.flatMap((fence) =>
111
113
  fence.kind === "mermaid" ? [fence] : [],
112
114
  );
@@ -136,44 +138,24 @@ export function parseViewerDocument(source: string, path = "spec") {
136
138
  for (const line of annotations) {
137
139
  if (line.text.includes("[[")) {
138
140
  return new DiffmapAnnotationError({
139
- reason: `Invalid reference in "${line.text}". Use [[id:old|new:start-end]] or [[path#symbol]]`,
141
+ reason: `Invalid reference in "${line.text}". Use [[path]], [[path#symbol]], [[path#L12-L30]], or [[<commit>:path#L12-L30]]; source-diff patch references were removed`,
140
142
  });
141
143
  }
142
144
  for (const ref of line.references) {
143
- if (ref.kind === "file") {
144
- if (
145
- ref.start !== undefined &&
146
- (!Number.isSafeInteger(ref.start) ||
147
- !Number.isSafeInteger(ref.end) ||
148
- ref.end! < ref.start)
149
- ) {
150
- return new DiffmapAnnotationError({
151
- reason: `Invalid file range in "${ref.path}"`,
152
- });
153
- }
154
- continue;
155
- }
156
- const diff = sourceDiffs.find((item) => item.id === ref.id);
157
- if (diff === undefined) {
145
+ if (ref.kind !== "file") continue;
146
+ if (ref.rev !== undefined && ref.symbol !== undefined) {
158
147
  return new DiffmapAnnotationError({
159
- reason: `Unknown source diff "${ref.id}"`,
148
+ reason: `Symbol references read current files. Use a line range with a commit: [[${ref.rev}:${ref.path}#L12-L30]]`,
160
149
  });
161
150
  }
162
- const inHunk = diff.fileDiff.hunks.some((hunk) => {
163
- const start =
164
- ref.side === "old" ? hunk.deletionStart : hunk.additionStart;
165
- const count =
166
- ref.side === "old" ? hunk.deletionCount : hunk.additionCount;
167
- return ref.start >= start && ref.end < start + count;
168
- });
169
151
  if (
170
- !Number.isSafeInteger(ref.start) ||
171
- !Number.isSafeInteger(ref.end) ||
172
- ref.end < ref.start ||
173
- !inHunk
152
+ ref.start !== undefined &&
153
+ (!Number.isSafeInteger(ref.start) ||
154
+ !Number.isSafeInteger(ref.end) ||
155
+ ref.end! < ref.start)
174
156
  ) {
175
157
  return new DiffmapAnnotationError({
176
- reason: `Range ${ref.side}:${ref.start}-${ref.end} is not in a hunk of "${ref.id}"`,
158
+ reason: `Invalid file range in "${ref.path}"`,
177
159
  });
178
160
  }
179
161
  }
@@ -181,14 +163,12 @@ export function parseViewerDocument(source: string, path = "spec") {
181
163
  return {
182
164
  nodes,
183
165
  headings: collectHeadings(nodes),
184
- sourceDiffs,
185
- hasReferences: annotations.some(
186
- (annotation) => annotation.references.length > 0,
187
- ),
166
+ references: annotations.flatMap((annotation) => annotation.references),
167
+ changeRevs,
188
168
  };
189
169
  }
190
170
 
191
- function collectFences(nodes: ViewerNode[]): Fence[] {
171
+ export function collectFences(nodes: ViewerNode[]): Fence[] {
192
172
  return nodes.flatMap((node) => {
193
173
  if (node.type === "view") return [node.fence];
194
174
  if (node.type === "element") return collectFences(node.children);
@@ -196,6 +176,14 @@ function collectFences(nodes: ViewerNode[]): Fence[] {
196
176
  });
197
177
  }
198
178
 
179
+ function collectReferences(nodes: ViewerNode[]): SourceAnnotation[] {
180
+ return nodes.flatMap((node) => {
181
+ if (node.type === "reference") return [node.annotation];
182
+ if (node.type === "element") return collectReferences(node.children);
183
+ return [];
184
+ });
185
+ }
186
+
199
187
  function mermaidWhere(source: string, index: number) {
200
188
  const line = mermaidFenceLine(source, index);
201
189
  if (line === undefined) return `diagram ${index}`;
@@ -283,31 +271,67 @@ function uniqueHeadingId(text: string, usedIds: Set<string>) {
283
271
 
284
272
  function viewerNodeText(node: ViewerNode): string {
285
273
  if (node.type === "text") return node.value;
274
+ if (node.type === "reference") return node.annotation.text;
286
275
  if (node.type !== "element") return "";
287
276
  return node.children.map(viewerNodeText).join("");
288
277
  }
289
278
 
290
- function fromNode(node: ComarkNode): ViewerNode[] {
279
+ function fromNode(node: ComarkNode, literal = false): ViewerNode[] {
291
280
  if (!Array.isArray(node)) {
292
- return [{ type: "text", value: node }];
281
+ return literal ? [{ type: "text", value: node }] : proseText(node);
293
282
  }
294
- const converted = fromElement(node);
283
+ const converted = fromElement(node, literal);
295
284
  if (converted === undefined) return [];
296
285
  if (Array.isArray(converted)) return converted;
297
286
  return [converted];
298
287
  }
299
288
 
289
+ /** Splits `[[path#symbol]]` references out of prose text. */
290
+ function proseText(value: string): ViewerNode[] {
291
+ const nodes: ViewerNode[] = [];
292
+ let last = 0;
293
+ for (const match of value.matchAll(/\[\[([^[\]\n]+)\]\]/g)) {
294
+ const reference = parseReference(match[1]!);
295
+ // The removed `[[id:old|new:N]]` patch syntax must fail, not render as text.
296
+ const removed =
297
+ reference === undefined && /^[\w-]+:(old|new):\d/.test(match[1]!);
298
+ if (reference === undefined && !removed) continue;
299
+ if (match.index > last)
300
+ nodes.push({ type: "text", value: value.slice(last, match.index) });
301
+ nodes.push({
302
+ type: "reference",
303
+ annotation:
304
+ reference === undefined
305
+ ? { text: match[0], references: [] }
306
+ : { text: referenceLabel(reference, true), references: [reference] },
307
+ });
308
+ last = match.index + match[0].length;
309
+ }
310
+ if (last < value.length)
311
+ nodes.push({ type: "text", value: value.slice(last) });
312
+ return nodes;
313
+ }
314
+
300
315
  function fromElement(
301
316
  element: ComarkElement,
317
+ literal: boolean,
302
318
  ): ViewerNode | ViewerNode[] | undefined {
303
319
  const [tag, , ...children] = element;
304
320
  if (tag === null) return undefined;
305
- if (tag === "template") return children.flatMap(fromNode);
321
+ // Code spans show the syntax literally; links cannot nest a button.
322
+ const literalChildren = literal || tag === "code" || tag === "a";
323
+ if (tag === "template")
324
+ return children.flatMap((child) => fromNode(child, literalChildren));
306
325
  if (tag === "pre") {
307
326
  return fenceView(stringAttr(element, "language"), comarkText(children));
308
327
  }
309
328
  if (tag === "mermaid") return fenceView("mermaid", comarkText(children));
310
329
  if (tag === "callstack") return fenceView("callstack", comarkText(children));
330
+ if (tag === "changes")
331
+ return fenceView(
332
+ "changes",
333
+ stringAttr(element, "rev") ?? comarkText(children),
334
+ );
311
335
  if (tag === "diff") {
312
336
  const path = stringAttr(element, "path");
313
337
  const lang = path === undefined ? "diff" : `diff:${path}`;
@@ -315,7 +339,9 @@ function fromElement(
315
339
  }
316
340
  if (tag === "file") return fileView(element, children);
317
341
  if (tag === "html") return htmlView(element, children);
318
- const convertedChildren = children.flatMap(fromNode);
342
+ const convertedChildren = children.flatMap((child) =>
343
+ fromNode(child, literalChildren),
344
+ );
319
345
  return {
320
346
  type: "element",
321
347
  tag,
@@ -352,7 +378,7 @@ function fileView(
352
378
  children: ComarkNode[],
353
379
  ): ViewerView | ViewerNode[] {
354
380
  const path = stringAttr(element, "path");
355
- if (path === undefined) return children.flatMap(fromNode);
381
+ if (path === undefined) return children.flatMap((child) => fromNode(child));
356
382
  const start = lineAttr(element, "start");
357
383
  const end = lineAttr(element, "end");
358
384
  // MDC ::file with no range loads the whole file.
package/src/serve.ts CHANGED
@@ -7,6 +7,7 @@ import { DiffmapFileError, DiffmapServeError } from "./errors.js";
7
7
  import { extractTitle } from "./extractDocument.js";
8
8
  import { parseViewerDocument } from "./parseViewer.js";
9
9
  import { findDefinition, readSourceReference } from "./definitions.js";
10
+ import { listDeltas, readChanges, readDocumentChanges } from "./git.js";
10
11
  import {
11
12
  registerRunningDiffmap,
12
13
  unregisterRunningDiffmap,
@@ -58,6 +59,8 @@ export async function startServer(input: StartServerInput) {
58
59
 
59
60
  const parsed = parseViewerDocument(source, filePath);
60
61
  if (parsed instanceof Error) return parsed;
62
+ const changes = await readDocumentChanges(workspaceRoot, parsed);
63
+ if (changes instanceof Error) return changes;
61
64
 
62
65
  const title = extractTitle(source);
63
66
 
@@ -106,7 +109,9 @@ export async function startServer(input: StartServerInput) {
106
109
  (pathname === "/" ||
107
110
  pathname === "/__diffmap/file" ||
108
111
  pathname === "/__diffmap/definition" ||
109
- pathname === "/__diffmap/source")
112
+ pathname === "/__diffmap/source" ||
113
+ pathname === "/__diffmap/changes" ||
114
+ pathname === "/__diffmap/deltas")
110
115
  ) {
111
116
  inactivityTimer?.refresh();
112
117
  }
@@ -261,6 +266,34 @@ async function handleDiffmapRequest(input: {
261
266
  );
262
267
  return;
263
268
  }
269
+ if (parsed.pathname === "/__diffmap/changes" && input.method === "GET") {
270
+ const changes = await readChanges(
271
+ input.workspaceRoot,
272
+ parsed.searchParams.get("rev") ?? "",
273
+ );
274
+ input.res.statusCode = changes instanceof Error ? 400 : 200;
275
+ input.res.setHeader("content-type", "application/json; charset=utf-8");
276
+ input.res.end(
277
+ JSON.stringify(
278
+ changes instanceof Error ? { error: changes.message } : { changes },
279
+ ),
280
+ );
281
+ return;
282
+ }
283
+ if (parsed.pathname === "/__diffmap/deltas" && input.method === "GET") {
284
+ const deltas = await listDeltas(
285
+ input.workspaceRoot,
286
+ parsed.searchParams.get("rev") ?? undefined,
287
+ );
288
+ input.res.statusCode = deltas instanceof Error ? 400 : 200;
289
+ input.res.setHeader("content-type", "application/json; charset=utf-8");
290
+ input.res.end(
291
+ JSON.stringify(
292
+ deltas instanceof Error ? { error: deltas.message } : { deltas },
293
+ ),
294
+ );
295
+ return;
296
+ }
264
297
  if (parsed.pathname === "/__diffmap/shutdown" && input.method === "POST") {
265
298
  input.res.statusCode = 200;
266
299
  input.res.setHeader("content-type", "text/plain; charset=utf-8");
package/src/share.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { DiffmapFileError, DiffmapShareError } from "./errors.js";
6
7
  import { extractTitle } from "./extractDocument.js";
7
8
  import { extractGistId, gistViewerUrl } from "./gist/route.ts";
8
- import { parseViewerDocument } from "./parseViewer.js";
9
+ import { GIST_CHANGES_FILE, GIST_FILES_FILE } from "./changes.js";
10
+ import { readSourceReference } from "./definitions.js";
11
+ import { readChanges, readDocumentChanges } from "./git.js";
12
+ import {
13
+ collectFences,
14
+ parseViewerDocument,
15
+ type ViewerDocument,
16
+ } from "./parseViewer.js";
9
17
 
10
18
  const execFileAsync = promisify(execFile);
11
19
 
@@ -17,6 +25,7 @@ export type ShareResult = {
17
25
 
18
26
  export async function shareMarkdownFile(input: {
19
27
  filePath: string;
28
+ workspaceRoot: string;
20
29
  isPublic: boolean;
21
30
  }) {
22
31
  const filePath = path.resolve(input.filePath);
@@ -32,6 +41,19 @@ export async function shareMarkdownFile(input: {
32
41
 
33
42
  const parsed = parseViewerDocument(source, filePath);
34
43
  if (parsed instanceof Error) return parsed;
44
+ // The hosted viewer has no git, so the gist carries a snapshot of each commit.
45
+ const changes = await readDocumentChanges(input.workspaceRoot, parsed);
46
+ if (changes instanceof Error) return changes;
47
+ // An uncommitted changes card shows the tree as it is when you share.
48
+ for (const rev of parsed.changeRevs) {
49
+ if (changes.has(rev)) continue;
50
+ const loaded = await readChanges(input.workspaceRoot, rev);
51
+ if (loaded instanceof Error) return loaded;
52
+ changes.set(rev, loaded);
53
+ }
54
+ // It has no files either, so excerpts and references without a commit read these.
55
+ const files = readDocumentFiles(input.workspaceRoot, parsed);
56
+ if (files instanceof Error) return files;
35
57
 
36
58
  const auth = await runGh(["auth", "status"]);
37
59
  if (auth instanceof DiffmapShareError) return auth;
@@ -39,8 +61,25 @@ export async function shareMarkdownFile(input: {
39
61
  const args = ["gist", "create"];
40
62
  if (input.isPublic) args.push("--public");
41
63
  args.push("--desc", extractTitle(source), filePath);
64
+ const snapshots = [
65
+ { name: GIST_CHANGES_FILE, value: Object.fromEntries(changes) },
66
+ { name: GIST_FILES_FILE, value: files },
67
+ ].filter((snapshot) => Object.keys(snapshot.value).length > 0);
68
+ const snapshotDir =
69
+ snapshots.length === 0
70
+ ? undefined
71
+ : await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-share-"));
72
+ if (snapshotDir !== undefined) {
73
+ for (const snapshot of snapshots) {
74
+ const snapshotPath = path.join(snapshotDir, snapshot.name);
75
+ await fs.writeFile(snapshotPath, JSON.stringify(snapshot.value));
76
+ args.push(snapshotPath);
77
+ }
78
+ }
42
79
 
43
80
  const created = await runGh(args);
81
+ if (snapshotDir !== undefined)
82
+ await fs.rm(snapshotDir, { recursive: true, force: true });
44
83
  if (created instanceof DiffmapShareError) return created;
45
84
 
46
85
  const combined = `${created.stderr}\n${created.stdout}`;
@@ -63,6 +102,28 @@ export async function shareMarkdownFile(input: {
63
102
  };
64
103
  }
65
104
 
105
+ /** Current contents of every file an excerpt or a reference without a commit reads, by path. */
106
+ function readDocumentFiles(root: string, document: ViewerDocument) {
107
+ const paths = new Set<string>();
108
+ for (const reference of document.references) {
109
+ if (reference.kind === "file" && reference.rev === undefined)
110
+ paths.add(reference.path);
111
+ }
112
+ for (const fence of collectFences(document.nodes)) {
113
+ if (fence.kind === "file") paths.add(fence.path);
114
+ }
115
+ const files: Record<string, string> = {};
116
+ for (const filePath of paths) {
117
+ const read = readSourceReference(
118
+ root,
119
+ new URLSearchParams({ path: filePath }),
120
+ );
121
+ if (read instanceof Error) return read;
122
+ files[filePath] = read.contents;
123
+ }
124
+ return files;
125
+ }
126
+
66
127
  async function runGh(args: string[]) {
67
128
  try {
68
129
  return await execFileAsync("gh", args, {
@@ -1,5 +1,12 @@
1
- import { backgroundColor, colors, focusRing, spacing, text } from "maui";
2
- import { style, useStyles } from "purse-styles";
1
+ import {
2
+ backgroundColor,
3
+ colors,
4
+ focusRing,
5
+ LARGE_SCALE,
6
+ spacing,
7
+ text,
8
+ } from "maui";
9
+ import { defineVars, style, useStyles } from "purse-styles";
3
10
  import { codeFontFamily } from "../codeFont.js";
4
11
  import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
5
12
  import { pierreShell } from "./pierre.js";
@@ -80,6 +87,10 @@ export function CallStackDiff(
80
87
  );
81
88
  }
82
89
 
90
+ const rowSize = defineVars({
91
+ height: { default: "28px", [LARGE_SCALE]: "40px" },
92
+ });
93
+
83
94
  const styles = {
84
95
  shell: style(
85
96
  text({ size: "sm", fontWeight: 500, color: "highContrast" }),
@@ -120,10 +131,11 @@ const styles = {
120
131
  alignItems: "stretch",
121
132
  flex: "1 1 auto",
122
133
  minWidth: 0,
123
- minHeight: "28px",
134
+ minHeight: rowSize.height,
124
135
  overflow: "hidden",
125
136
  },
126
137
  "& .stack-label": {
138
+ alignSelf: "center",
127
139
  flex: "1 1 auto",
128
140
  minWidth: 0,
129
141
  paddingBlock: "3px",