@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.
- package/README.md +64 -57
- package/index.html +18 -0
- package/package.json +7 -5
- package/src/annotations.ts +36 -29
- package/src/changes.ts +235 -0
- package/src/cli.ts +1 -0
- package/src/errors.ts +5 -0
- package/src/gist/GistApp.tsx +38 -17
- package/src/gist/fetchGist.ts +47 -0
- package/src/git.test.ts +402 -0
- package/src/git.ts +498 -0
- package/src/github/GitHubApp.tsx +39 -22
- package/src/github/fetchRepo.test.ts +59 -1
- package/src/github/fetchRepo.ts +99 -43
- package/src/github/patch.test.ts +18 -9
- package/src/github/patch.ts +30 -19
- package/src/host/HostHome.tsx +20 -7
- package/src/host/files.ts +5 -1
- package/src/host/home.md +17 -55
- package/src/host/home.test.ts +4 -3
- package/src/index.ts +1 -0
- package/src/parseFence.ts +6 -6
- package/src/parseViewer.test.ts +140 -0
- package/src/parseViewer.ts +104 -78
- package/src/serve.ts +34 -1
- package/src/share.ts +62 -1
- package/src/viewer/CallStackDiff.tsx +15 -3
- package/src/viewer/ChangesCard.tsx +267 -0
- package/src/viewer/ComarkView.tsx +83 -15
- package/src/viewer/Fence.tsx +9 -1
- package/src/viewer/MermaidBlock.tsx +99 -11
- package/src/viewer/RangePicker.tsx +526 -0
- package/src/viewer/SourceDiffPanel.tsx +254 -116
- package/src/viewer/TableOfContents.tsx +177 -93
- package/src/viewer/ViewerApp.tsx +34 -114
- package/src/viewer/changesSource.ts +91 -0
- package/src/viewer/sourceHeader.test.ts +2 -87
- package/src/viewer/sourceHeader.ts +4 -63
- package/src/viewer/sourceNavigation.ts +0 -43
- package/vite.config.ts +1 -0
- package/vite.gist.config.ts +57 -0
- package/src/viewer/TocButton.tsx +0 -27
package/src/parseViewer.test.ts
CHANGED
|
@@ -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
|
}
|
package/src/parseViewer.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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 =
|
|
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
|
|
88
|
+
const changeRevs: Rev[] = [];
|
|
70
89
|
for (const fence of fences) {
|
|
71
|
-
if (fence.kind
|
|
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: `
|
|
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 (
|
|
95
|
+
if (fence.kind !== "changes") continue;
|
|
96
|
+
if (!isRev(fence.rev)) {
|
|
98
97
|
return new DiffmapAnnotationError({
|
|
99
|
-
reason: `
|
|
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
|
-
|
|
101
|
+
changeRevs.push(fence.rev);
|
|
103
102
|
}
|
|
104
|
-
const annotations =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 [[
|
|
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
|
|
144
|
-
|
|
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: `
|
|
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
|
-
|
|
171
|
-
!Number.isSafeInteger(ref.
|
|
172
|
-
|
|
173
|
-
|
|
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: `
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
2
|
-
|
|
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:
|
|
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",
|