@tanishqkancharla/diffmap 0.1.4 → 0.2.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 (53) hide show
  1. package/README.md +69 -61
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +229 -0
  6. package/src/cli.test.ts +50 -0
  7. package/src/cli.ts +41 -5
  8. package/src/errors.ts +10 -0
  9. package/src/gist/GistApp.tsx +39 -20
  10. package/src/gist/GistStatus.tsx +2 -51
  11. package/src/gist/fetchGist.ts +23 -0
  12. package/src/git.test.ts +402 -0
  13. package/src/git.ts +498 -0
  14. package/src/github/GitHubApp.tsx +39 -22
  15. package/src/github/fetchRepo.test.ts +59 -1
  16. package/src/github/fetchRepo.ts +99 -43
  17. package/src/github/patch.test.ts +18 -9
  18. package/src/github/patch.ts +30 -19
  19. package/src/host/HostHome.tsx +86 -0
  20. package/src/host/files.ts +10 -0
  21. package/src/host/home.md +156 -0
  22. package/src/host/home.test.ts +83 -0
  23. package/src/index.ts +1 -0
  24. package/src/packageVersion.ts +7 -0
  25. package/src/parseFence.ts +6 -6
  26. package/src/parseViewer.test.ts +87 -0
  27. package/src/parseViewer.ts +103 -77
  28. package/src/serve.test.ts +306 -0
  29. package/src/serve.ts +48 -5
  30. package/src/share.ts +18 -0
  31. package/src/update.test.ts +19 -0
  32. package/src/update.ts +285 -0
  33. package/src/viewer/CallStackDiff.tsx +15 -3
  34. package/src/viewer/ChangesCard.tsx +267 -0
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/ComarkView.tsx +83 -15
  37. package/src/viewer/Fence.tsx +9 -1
  38. package/src/viewer/FileExcerpt.tsx +25 -3
  39. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  40. package/src/viewer/MermaidBlock.tsx +99 -11
  41. package/src/viewer/RangePicker.tsx +526 -0
  42. package/src/viewer/SourceDiffPanel.tsx +277 -121
  43. package/src/viewer/TableOfContents.tsx +313 -39
  44. package/src/viewer/ViewerApp.tsx +120 -68
  45. package/src/viewer/changesSource.ts +91 -0
  46. package/src/viewer/sourceHeader.test.ts +2 -87
  47. package/src/viewer/sourceHeader.ts +4 -63
  48. package/src/viewer/sourceNavigation.ts +33 -42
  49. package/src/viewer/viewerMode.ts +1 -1
  50. package/vite.config.ts +2 -1
  51. package/vite.gist.config.ts +57 -0
  52. package/src/viewer/Check.tsx +0 -24
  53. package/src/viewer/DoneButton.tsx +0 -32
@@ -0,0 +1,306 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile, spawn, type ChildProcess } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import net from "node:net";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+ import { setTimeout as delay } from "node:timers/promises";
10
+ import test from "node:test";
11
+ import { DiffmapServeError } from "./errors.js";
12
+ import { startServer, type DiffmapServer } from "./serve.js";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const srcDir = fileURLToPath(new URL(".", import.meta.url));
16
+ const packageRoot = path.resolve(srcDir, "..");
17
+ const cliPath = path.join(srcDir, "cli.ts");
18
+ const sampleSpec = path.join(packageRoot, "fixtures", "sample-spec.md");
19
+ const localUrlPattern = /http:\/\/127\.0\.0\.1:\d+/g;
20
+
21
+ function httpUrlPort(url: string) {
22
+ const parsed = new URL(url);
23
+ assert.equal(parsed.hostname, "127.0.0.1");
24
+ const port = Number(parsed.port);
25
+ assert.ok(Number.isInteger(port) && port > 0);
26
+ return port;
27
+ }
28
+
29
+ function assertStarted(
30
+ started: Awaited<ReturnType<typeof startServer>>,
31
+ ): DiffmapServer {
32
+ assert.equal(
33
+ started instanceof Error,
34
+ false,
35
+ started instanceof Error ? started.message : "",
36
+ );
37
+ if (started instanceof Error) throw started;
38
+ return started;
39
+ }
40
+
41
+ async function occupyPort(port: number) {
42
+ const server = net.createServer();
43
+ try {
44
+ await new Promise<void>((resolve, reject) => {
45
+ server.once("error", reject);
46
+ server.listen(port, "127.0.0.1", () => resolve());
47
+ });
48
+ return server;
49
+ } catch {
50
+ server.close();
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ async function listenEphemeral() {
56
+ const server = net.createServer();
57
+ await new Promise<void>((resolve, reject) => {
58
+ server.once("error", reject);
59
+ server.listen(0, "127.0.0.1", () => resolve());
60
+ });
61
+ return server;
62
+ }
63
+
64
+ function boundPort(server: net.Server) {
65
+ const address = server.address();
66
+ assert.ok(address !== null && typeof address !== "string");
67
+ return address.port;
68
+ }
69
+
70
+ async function closeTcp(server: net.Server | undefined) {
71
+ if (server === undefined) return;
72
+ await new Promise<void>((resolve) => {
73
+ server.close(() => resolve());
74
+ });
75
+ }
76
+
77
+ async function fetchOk(url: string) {
78
+ const response = await fetch(url, {
79
+ headers: { accept: "text/markdown" },
80
+ });
81
+ assert.equal(response.ok, true);
82
+ const body = await response.text();
83
+ assert.match(body, /Spec fixture/);
84
+ }
85
+
86
+ async function waitForClosed(server: DiffmapServer, timeoutMs = 10_000) {
87
+ await Promise.race([
88
+ server.closed,
89
+ delay(timeoutMs).then(() => {
90
+ throw new Error(`server did not close: ${server.url}`);
91
+ }),
92
+ ]);
93
+ }
94
+
95
+ test(
96
+ "startServer binds distinct free ports when 4177 is taken",
97
+ { timeout: 90_000 },
98
+ async () => {
99
+ const occupied = await occupyPort(4177);
100
+ try {
101
+ const first = assertStarted(
102
+ await startServer({
103
+ filePath: sampleSpec,
104
+ workspaceRoot: packageRoot,
105
+ }),
106
+ );
107
+ const second = assertStarted(
108
+ await startServer({
109
+ filePath: sampleSpec,
110
+ workspaceRoot: packageRoot,
111
+ }),
112
+ );
113
+ try {
114
+ const firstPort = httpUrlPort(first.url);
115
+ const secondPort = httpUrlPort(second.url);
116
+ assert.notEqual(firstPort, secondPort);
117
+ assert.notEqual(firstPort, 4177);
118
+ assert.notEqual(secondPort, 4177);
119
+ await fetchOk(first.url);
120
+ await fetchOk(second.url);
121
+ } finally {
122
+ await first.shutdown();
123
+ await second.shutdown();
124
+ await waitForClosed(first);
125
+ await waitForClosed(second);
126
+ }
127
+ } finally {
128
+ await closeTcp(occupied);
129
+ }
130
+ },
131
+ );
132
+
133
+ test(
134
+ "startServer --port pins and fails when that port is taken",
135
+ { timeout: 90_000 },
136
+ async () => {
137
+ const holder = await listenEphemeral();
138
+ const port = boundPort(holder);
139
+
140
+ const blocked = await startServer({
141
+ filePath: sampleSpec,
142
+ workspaceRoot: packageRoot,
143
+ port,
144
+ });
145
+ assert.equal(blocked instanceof DiffmapServeError, true);
146
+ if (!(blocked instanceof DiffmapServeError)) return;
147
+ assert.match(blocked.message, /already in use/i);
148
+
149
+ await closeTcp(holder);
150
+
151
+ const pinned = assertStarted(
152
+ await startServer({
153
+ filePath: sampleSpec,
154
+ workspaceRoot: packageRoot,
155
+ port,
156
+ }),
157
+ );
158
+ try {
159
+ assert.equal(httpUrlPort(pinned.url), port);
160
+ await fetchOk(pinned.url);
161
+ } finally {
162
+ await pinned.shutdown();
163
+ await waitForClosed(pinned);
164
+ }
165
+ },
166
+ );
167
+
168
+ test(
169
+ "startServer Close server still stops an ephemeral listener",
170
+ { timeout: 90_000 },
171
+ async () => {
172
+ const started = assertStarted(
173
+ await startServer({
174
+ filePath: sampleSpec,
175
+ workspaceRoot: packageRoot,
176
+ }),
177
+ );
178
+ const response = await fetch(`${started.url}/__diffmap/shutdown`, {
179
+ method: "POST",
180
+ });
181
+ assert.equal(response.ok, true);
182
+ await waitForClosed(started);
183
+ await assert.rejects(() => fetch(started.url));
184
+ },
185
+ );
186
+
187
+ type SpawnedCli = {
188
+ child: ChildProcess;
189
+ output: () => string;
190
+ };
191
+
192
+ function spawnCli(args: string[]) {
193
+ let stdout = "";
194
+ let stderr = "";
195
+ const child = spawn(process.execPath, ["--import", "tsx", cliPath, ...args], {
196
+ cwd: packageRoot,
197
+ stdio: ["ignore", "pipe", "pipe"],
198
+ });
199
+ child.stdout?.on("data", (chunk: Buffer) => {
200
+ stdout += chunk.toString();
201
+ });
202
+ child.stderr?.on("data", (chunk: Buffer) => {
203
+ stderr += chunk.toString();
204
+ });
205
+ return {
206
+ child,
207
+ output: () => `${stdout}${stderr}`,
208
+ };
209
+ }
210
+
211
+ function firstLocalUrl(text: string) {
212
+ return text.match(localUrlPattern)?.[0];
213
+ }
214
+
215
+ async function waitForLocalUrl(spawned: SpawnedCli, timeoutMs = 60_000) {
216
+ const start = Date.now();
217
+ while (Date.now() - start < timeoutMs) {
218
+ if (spawned.child.exitCode !== null) {
219
+ throw new Error(
220
+ `cli exited ${spawned.child.exitCode} before listen:\n${spawned.output()}`,
221
+ );
222
+ }
223
+ const url = firstLocalUrl(spawned.output());
224
+ if (url !== undefined) return url;
225
+ await delay(50);
226
+ }
227
+ throw new Error(`cli printed no listen url:\n${spawned.output()}`);
228
+ }
229
+
230
+ async function waitForExit(child: ChildProcess, timeoutMs = 15_000) {
231
+ if (child.exitCode !== null) return;
232
+ await Promise.race([
233
+ new Promise<void>((resolve) => {
234
+ child.once("exit", () => resolve());
235
+ }),
236
+ delay(timeoutMs).then(() => {
237
+ child.kill("SIGKILL");
238
+ throw new Error("cli did not exit");
239
+ }),
240
+ ]);
241
+ }
242
+
243
+ async function stopCli(spawned: SpawnedCli, url: string) {
244
+ const response = await fetch(`${url}/__diffmap/shutdown`, {
245
+ method: "POST",
246
+ }).catch(() => undefined);
247
+ if (response === undefined || !response.ok) {
248
+ spawned.child.kill("SIGTERM");
249
+ }
250
+ await waitForExit(spawned.child);
251
+ }
252
+
253
+ async function withTempSpecs(
254
+ run: (firstFile: string, secondFile: string) => Promise<void>,
255
+ ) {
256
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-serve-"));
257
+ const firstFile = path.join(dir, "one.md");
258
+ const secondFile = path.join(dir, "two.md");
259
+ const source = await fs.readFile(sampleSpec, "utf8");
260
+ await Promise.all([
261
+ fs.writeFile(firstFile, source),
262
+ fs.writeFile(secondFile, source),
263
+ ]);
264
+ try {
265
+ await run(firstFile, secondFile);
266
+ } finally {
267
+ await fs.rm(dir, { recursive: true, force: true });
268
+ }
269
+ }
270
+
271
+ test(
272
+ "diffmap serve prints a free port and list tracks both servers",
273
+ { timeout: 90_000 },
274
+ async () => {
275
+ await withTempSpecs(async (firstFile, secondFile) => {
276
+ const first = spawnCli(["serve", firstFile]);
277
+ const second = spawnCli([secondFile]);
278
+ try {
279
+ const firstUrl = await waitForLocalUrl(first);
280
+ const secondUrl = await waitForLocalUrl(second);
281
+ assert.notEqual(firstUrl, secondUrl);
282
+ await fetchOk(firstUrl);
283
+ await fetchOk(secondUrl);
284
+
285
+ const listed = await execFileAsync(
286
+ process.execPath,
287
+ ["--import", "tsx", cliPath, "list"],
288
+ {
289
+ cwd: packageRoot,
290
+ timeout: 20_000,
291
+ maxBuffer: 1_000_000,
292
+ },
293
+ );
294
+ const listOutput = `${listed.stdout}${listed.stderr}`;
295
+ assert.equal(listOutput.includes(firstUrl), true);
296
+ assert.equal(listOutput.includes(secondUrl), true);
297
+
298
+ await stopCli(first, firstUrl);
299
+ await stopCli(second, secondUrl);
300
+ } finally {
301
+ first.child.kill("SIGTERM");
302
+ second.child.kill("SIGTERM");
303
+ }
304
+ });
305
+ },
306
+ );
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,
@@ -22,7 +23,7 @@ export type DiffmapServer = {
22
23
  export type StartServerInput = {
23
24
  filePath: string;
24
25
  workspaceRoot: string;
25
- port: number;
26
+ port?: number;
26
27
  };
27
28
 
28
29
  const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
@@ -45,6 +46,7 @@ function createClosedBarrier() {
45
46
  export async function startServer(input: StartServerInput) {
46
47
  const filePath = path.resolve(input.filePath);
47
48
  const workspaceRoot = path.resolve(input.workspaceRoot);
49
+ const port = input.port ?? 0;
48
50
  const source = await fs.readFile(filePath, "utf8").catch(
49
51
  (cause) =>
50
52
  new DiffmapFileError({
@@ -57,6 +59,8 @@ export async function startServer(input: StartServerInput) {
57
59
 
58
60
  const parsed = parseViewerDocument(source, filePath);
59
61
  if (parsed instanceof Error) return parsed;
62
+ const changes = await readDocumentChanges(workspaceRoot, parsed);
63
+ if (changes instanceof Error) return changes;
60
64
 
61
65
  const title = extractTitle(source);
62
66
 
@@ -82,9 +86,9 @@ export async function startServer(input: StartServerInput) {
82
86
  configFile: path.join(packageRoot, "vite.config.ts"),
83
87
  root: packageRoot,
84
88
  server: {
85
- port: input.port,
89
+ port,
86
90
  host: "127.0.0.1",
87
- strictPort: input.port !== 0,
91
+ strictPort: port !== 0,
88
92
  fs: {
89
93
  allow: [packageRoot, workspaceRoot, path.dirname(filePath)],
90
94
  },
@@ -105,7 +109,9 @@ export async function startServer(input: StartServerInput) {
105
109
  (pathname === "/" ||
106
110
  pathname === "/__diffmap/file" ||
107
111
  pathname === "/__diffmap/definition" ||
108
- pathname === "/__diffmap/source")
112
+ pathname === "/__diffmap/source" ||
113
+ pathname === "/__diffmap/changes" ||
114
+ pathname === "/__diffmap/deltas")
109
115
  ) {
110
116
  inactivityTimer?.refresh();
111
117
  }
@@ -138,7 +144,16 @@ export async function startServer(input: StartServerInput) {
138
144
  ],
139
145
  });
140
146
 
141
- await vite.listen(input.port);
147
+ const listened = await vite.listen(port).catch(
148
+ (cause) =>
149
+ new DiffmapServeError({
150
+ reason: cause instanceof Error ? cause.message : "listen failed",
151
+ }),
152
+ );
153
+ if (listened instanceof Error) {
154
+ await shutdown();
155
+ return listened;
156
+ }
142
157
  const localUrl = vite.resolvedUrls?.local[0];
143
158
  if (localUrl === undefined) {
144
159
  await shutdown();
@@ -251,6 +266,34 @@ async function handleDiffmapRequest(input: {
251
266
  );
252
267
  return;
253
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
+ }
254
297
  if (parsed.pathname === "/__diffmap/shutdown" && input.method === "POST") {
255
298
  input.res.statusCode = 200;
256
299
  input.res.setHeader("content-type", "text/plain; charset=utf-8");
package/src/share.ts CHANGED
@@ -1,10 +1,13 @@
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";
9
+ import { GIST_CHANGES_FILE } from "./changes.js";
10
+ import { readDocumentChanges } from "./git.js";
8
11
  import { parseViewerDocument } from "./parseViewer.js";
9
12
 
10
13
  const execFileAsync = promisify(execFile);
@@ -17,6 +20,7 @@ export type ShareResult = {
17
20
 
18
21
  export async function shareMarkdownFile(input: {
19
22
  filePath: string;
23
+ workspaceRoot: string;
20
24
  isPublic: boolean;
21
25
  }) {
22
26
  const filePath = path.resolve(input.filePath);
@@ -32,6 +36,9 @@ export async function shareMarkdownFile(input: {
32
36
 
33
37
  const parsed = parseViewerDocument(source, filePath);
34
38
  if (parsed instanceof Error) return parsed;
39
+ // The hosted viewer has no git, so the gist carries a snapshot of each commit.
40
+ const changes = await readDocumentChanges(input.workspaceRoot, parsed);
41
+ if (changes instanceof Error) return changes;
35
42
 
36
43
  const auth = await runGh(["auth", "status"]);
37
44
  if (auth instanceof DiffmapShareError) return auth;
@@ -39,8 +46,19 @@ export async function shareMarkdownFile(input: {
39
46
  const args = ["gist", "create"];
40
47
  if (input.isPublic) args.push("--public");
41
48
  args.push("--desc", extractTitle(source), filePath);
49
+ const snapshotDir =
50
+ changes.size === 0
51
+ ? undefined
52
+ : await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-share-"));
53
+ if (snapshotDir !== undefined) {
54
+ const snapshot = path.join(snapshotDir, GIST_CHANGES_FILE);
55
+ await fs.writeFile(snapshot, JSON.stringify(Object.fromEntries(changes)));
56
+ args.push(snapshot);
57
+ }
42
58
 
43
59
  const created = await runGh(args);
60
+ if (snapshotDir !== undefined)
61
+ await fs.rm(snapshotDir, { recursive: true, force: true });
44
62
  if (created instanceof DiffmapShareError) return created;
45
63
 
46
64
  const combined = `${created.stderr}\n${created.stdout}`;
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { compareVersions, resolveUpdateArgv } from "./update.js";
4
+
5
+ test("compareVersions orders versions by semver precedence", () => {
6
+ assert.equal(compareVersions("0.1.4", "0.1.4"), 0);
7
+ assert.equal(compareVersions("0.1.4", "0.1.5"), -1);
8
+ assert.equal(compareVersions("0.1.10", "0.1.9"), 1);
9
+ assert.equal(compareVersions("1.0.0-rc.1", "1.0.0"), -1);
10
+ });
11
+
12
+ test("update and --update run the same command", () => {
13
+ assert.deepEqual(resolveUpdateArgv(["update"]), ["update"]);
14
+ assert.deepEqual(resolveUpdateArgv(["--update"]), ["update"]);
15
+ assert.deepEqual(resolveUpdateArgv(["serve", "spec.md"]), [
16
+ "serve",
17
+ "spec.md",
18
+ ]);
19
+ });