@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
package/README.md CHANGED
@@ -21,17 +21,17 @@ Hosted viewer: `https://diffmap.dev/g/<gistId>` (optional `/<file.md>`; `#headin
21
21
  ## Agent skills
22
22
 
23
23
  ```sh
24
- npx skills add tanishqkancharla/diffmap --skill generate-spec
24
+ npx skills add tanishqkancharla/diffmap --skill generate-diffmap
25
25
  ```
26
26
 
27
27
  Keeps a spec in `specs/` that stays true to what's planned versus already in the tree. Serve it with diffmap. Share from a GitHub PR (`https://diffmap.dev/<owner>/<repo>/pull/<n>/specs/<name>.md`) or `npx @tanishqkancharla/diffmap share`.
28
28
 
29
29
  Options:
30
30
 
31
- - `--port <n>` — listen port (default `4177`)
31
+ - `--port <n>` — pin a listen port (default: a free port)
32
32
  - `--root <dir>` — workspace root for file excerpts (default the directory you ran the command from)
33
33
 
34
- **Done** in the top right stops the server.
34
+ **Close server** in the top right stops the server. Hosted gist and GitHub viewers have no local server, so they omit this control.
35
35
 
36
36
  The server also stops after 24 hours without a page or source request.
37
37
  Loading or refreshing the page resets the timer; background health checks and
@@ -43,11 +43,19 @@ List every running viewer, including viewers using custom ports:
43
43
  npx @tanishqkancharla/diffmap list
44
44
  ```
45
45
 
46
+ Update a global install to the latest `@tanishqkancharla/diffmap` on npm. `diffmap --update` does the same thing:
47
+
48
+ ```sh
49
+ diffmap update
50
+ ```
51
+
52
+ If you are already on the latest version, it says so and exits. Otherwise it runs the global install for the package manager that installed diffmap (`npm install --global`, `pnpm add --global`, or `bun add --global`) and prints the old and new versions. Under `npx`, or when it cannot tell how diffmap was installed, it prints the command to run instead of installing anything.
53
+
46
54
  Request the page with `Accept: text/markdown` to read the current source file
47
- instead of the rendered HTML:
55
+ instead of the rendered HTML. Use the URL printed by `serve`:
48
56
 
49
57
  ```sh
50
- curl -H 'Accept: text/markdown' http://127.0.0.1:4177/
58
+ curl -H 'Accept: text/markdown' http://127.0.0.1:<port>/
51
59
  ```
52
60
 
53
61
  ## Library
@@ -60,7 +68,7 @@ import {
60
68
  } from "@tanishqkancharla/diffmap";
61
69
  ```
62
70
 
63
- `parseViewerDocument` turns markdown into the page document with [md4x](https://github.com/unjs/md4x). `startServer` listens. The generate-spec skill owns document shape; the viewer does not.
71
+ `parseViewerDocument` turns markdown into the page document with [md4x](https://github.com/unjs/md4x). `startServer` listens. The generate-diffmap skill owns document shape; the viewer does not.
64
72
 
65
73
  ## Fences
66
74
 
@@ -70,6 +78,7 @@ import {
70
78
  | `callstack` or `diff` containing `└──` / `├──` | Interactive stack rows, no file header |
71
79
  | `diff` or `diff:path` with a file path | Pierre patch with Pierre’s file header |
72
80
  | `diff` with no path | Pierre patch, no file header |
81
+ | `changes` with a commit, range, or empty body | Files changed card with a Review button; empty means uncommitted changes |
73
82
  | `start:end:path` | Pierre file excerpt with Pierre’s file header |
74
83
  | `html` | Trusted HTML from this file. diffmap does not sanitize it. Only use it for local files you wrote. |
75
84
  | other langs | Maui `CodeBlock` |
@@ -99,6 +108,9 @@ startServer
99
108
  ::file{path="src/cli.ts" start="1" end="20"}
100
109
  ::
101
110
 
111
+ ::changes{rev="a1b2c3d"}
112
+ ::
113
+
102
114
  ::html
103
115
  <aside>Note.</aside>
104
116
  ::
@@ -108,58 +120,43 @@ startServer
108
120
 
109
121
  ## Link call stacks to source changes
110
122
 
111
- Append `[[path/to/file.ts#symbolName]]` to a call stack line to link a symbol.
112
- Paths are relative to `--root`. Symbol lookup supports TypeScript and JavaScript
113
- declarations, including qualified names such as `[[src/store.ts#Store.save]]`.
114
- Ambiguous names show the qualified names you can use instead.
123
+ Append a reference to a call stack line to link it. Paths are relative to `--root`.
124
+
125
+ - `[[path/to/file.ts#symbolName]]` links a TypeScript or JavaScript declaration,
126
+ including qualified names such as `[[src/store.ts#Store.save]]`. Ambiguous
127
+ names show the qualified names you can use instead.
128
+ - `[[path/to/file]]` links a whole file and `[[path/to/file#L12-L30]]` a line
129
+ range. These work with any language.
130
+ - `[[<sha>:path/to/file#L12-L30]]` or `[[<sha>:path/to/file]]` links a change
131
+ in a commit. `<sha>` is a 7–40 character lowercase commit SHA, or a range
132
+ `<sha>..<sha>`.
115
133
 
116
134
  ```callstack
117
- handleRequest [[src/request.ts#handleRequest]]
118
- +└── validateInput # reject invalid input [[src/validation.ts#validateInput]]
135
+ shareMarkdownFile [[src/share.ts#shareMarkdownFile]]
136
+ +└── gistViewerUrl # print the diffmap.dev link [[d14ae1a:src/share.ts#L55-L59]]
119
137
  ```
120
138
 
121
- The panel highlights the declaration in the current workspace file. If an
122
- included source patch overlaps that declaration and its new-side text matches
123
- the workspace, the panel highlights the patch instead. Symbol references do not
124
- require a `source-diff` fence. Use `[[path/to/file]]` for a whole file or
125
- `[[path/to/file#L12-L30]]` for a file range; these work with other languages too.
139
+ A reference without a commit opens the Diff panel on uncommitted changes
140
+ (working tree against HEAD), focused on that file or range. If the file has no
141
+ uncommitted changes, the panel shows the current file.
126
142
 
127
- To reference an exact change, including removed code, use the patch syntax below.
128
- Symbol names resolve against current files; use an `old` reference for a deleted
129
- symbol or a historical version.
143
+ A commit reference opens the Diff panel on every file changed in that commit or
144
+ range, with a sticky header per file, scrolled to the linked file and lines.
145
+ Line numbers are the file's lines after the commit. The file must be changed in
146
+ that commit and the range must fit the new file; `serve` and `share` run git and
147
+ fail at startup otherwise. Deleted lines cannot be referenced, and a commit
148
+ cannot be combined with `#symbol`.
130
149
 
131
- Append `[[id:side:start-end]]` to a call stack line. `side` is `old` or `new`;
132
- line numbers refer to that version of the source file, not the patch. A single
133
- line can use `[[id:new:12]]`. Multiple references on one stack line are allowed.
134
- The references are hidden in the rendered stack, and linked rows support mouse
135
- clicks and keyboard activation.
150
+ Multiple references on one stack line are allowed. The references are hidden in
151
+ the rendered stack, and linked rows support mouse clicks and keyboard activation.
136
152
 
137
- Define each ID once in a `source-diff:id:path` fence anywhere in the document.
138
- Copy the file's actual Git patch, including its `diff --git`, file headers, and
139
- `@@` hunk headers. Keep enough context to explain the change. Each reference
140
- range must fit within one included hunk. Use the new path for renamed files and
141
- the old path for deleted files.
153
+ A `changes` fence shows a commit's files as a card. Each row shows the file name
154
+ and +N −M. **Review** opens the Diff panel on all files; a row focuses that file.
155
+ An empty body (or `working`) means uncommitted changes.
142
156
 
143
157
  ````md
144
- ```callstack
145
- handleRequest
146
- -└── saveUnchecked [[request:old:12]]
147
- +├── validateInput # reject invalid input [[request:new:12-13]]
148
- └── saveRecord
149
- ```
150
-
151
- ```source-diff:request:src/request.ts
152
- diff --git a/src/request.ts b/src/request.ts
153
- --- a/src/request.ts
154
- +++ b/src/request.ts
155
- @@ -10,5 +10,6 @@
156
- export function handleRequest(input: Input) {
157
- const record = input.record;
158
- - saveUnchecked(record);
159
- + const error = validateInput(record);
160
- + if (error instanceof Error) return error;
161
- return saveRecord(record);
162
- }
158
+ ```changes
159
+ a1b2c3d
163
160
  ```
164
161
  ````
165
162
 
@@ -167,36 +164,38 @@ References open in one shared panel beside the walkthrough. Selecting a stack
167
164
  line or diagram element highlights it and scrolls the panel to the linked code
168
165
  range. When a target has several references, buttons above the source viewer
169
166
  select among them. On narrow windows the source panel sits below the walkthrough.
170
- Documents without references or source patches keep the single-panel layout.
167
+ Documents without references keep the single-panel layout.
171
168
 
172
- Unknown IDs, duplicate definitions, malformed references, mismatched paths, and
173
- ranges outside the included hunks produce a parse error. Source patches are
174
- embedded snapshots; the viewer does not regenerate them from the working tree.
169
+ Malformed references produce a parse error.
170
+
171
+ On a GitHub-hosted spec, commit diffs come from the GitHub API. It returns patch
172
+ hunks only, so only lines near changes are reachable, and the Diff button opens
173
+ the pull request's full diff. `diffmap share` saves every referenced commit to a
174
+ `diffmap-changes.json` file in the gist. Uncommitted references only work in a
175
+ local viewer.
175
176
 
176
177
  Cmd-click (or Ctrl-click) a TypeScript or JavaScript symbol in the source panel
177
178
  to open its definition. Use Back to retrace navigation and return to the reference.
178
179
  Resolution uses the workspace's TypeScript configuration and current files.
179
- Deleted files and lines that no longer match the workspace show a message;
180
- their call stack references still highlight the embedded old-side diff.
181
180
 
182
181
  Run `npx @tanishqkancharla/diffmap fixtures/annotations.md` for an example
183
- with old/new references, multiple files, and unchanged context.
182
+ with commit references, multiple files, and unchanged context.
184
183
 
185
184
  ## Link Mermaid nodes and edges
186
185
 
187
186
  Inside a Mermaid fence, add `%% ref node:<id> [[reference]]` to link a node or
188
187
  participant, or `%% ref edge:<index> [[reference]]` to link an edge or sequence
189
188
  message. Edge indices start at zero in declaration order. The same file, symbol,
190
- and old/new patch references work here. Keep one directive per target; append
189
+ and commit references work here. Keep one directive per target; append
191
190
  multiple references to that directive when needed.
192
191
 
193
192
  ````md
194
193
  ```mermaid
195
194
  flowchart LR
196
- A[Request] -->|Validate| B[Validation]
197
- %% ref node:A [[src/request.ts#handleRequest]]
198
- %% ref node:B [[src/validation.ts#validateInput]]
199
- %% ref edge:0 [[request:new:12-13]]
195
+ A[ViewerApp] -->|Local mode only| B[CloseServerButton]
196
+ %% ref node:A [[src/viewer/ViewerApp.tsx#ViewerApp]]
197
+ %% ref node:B [[dae6b98:src/viewer/CloseServerButton.tsx#L8-L16]]
198
+ %% ref edge:0 [[dae6b98:src/viewer/ViewerApp.tsx#L219-L227]]
200
199
  ```
201
200
 
202
201
  ```mermaid
@@ -213,3 +212,12 @@ Click a linked shape, edge line, or edge label, or focus it with Tab and press
213
212
  Enter or Space. Directives are Mermaid comments, so they remain hidden and do
214
213
  not change the diagram when rendered by other Mermaid tools. Missing diagram
215
214
  targets display an error. See [the runnable example](fixtures/references.md).
215
+
216
+ ## Link from prose
217
+
218
+ Write any reference in a paragraph, list item, or table cell, such as
219
+ `See [[src/store.ts#Store.save]] for the write path.` or
220
+ `Close server landed in [[dae6b98:src/viewer/CloseServerButton.tsx]].` It
221
+ renders as clickable inline code labeled with the file name and fragment
222
+ (`store.ts#Store.save`) and opens the same source panel. References inside code spans and link text stay
223
+ literal, so `` `[[path]]` `` shows the syntax itself.
package/index.html CHANGED
@@ -27,6 +27,24 @@
27
27
 
28
28
  document.documentElement.dataset.theme = theme;
29
29
  document.documentElement.style.colorScheme = theme;
30
+
31
+ var scale = "system";
32
+ try {
33
+ var storedScale = window.localStorage.getItem("maui-scale");
34
+ if (
35
+ storedScale === "system" ||
36
+ storedScale === "medium" ||
37
+ storedScale === "large"
38
+ ) {
39
+ scale = storedScale;
40
+ }
41
+ } catch {}
42
+ document.documentElement.dataset.scale =
43
+ scale === "system"
44
+ ? window.matchMedia("(pointer: coarse)").matches
45
+ ? "large"
46
+ : "medium"
47
+ : scale;
30
48
  })();
31
49
  </script>
32
50
  <title>diffmap</title>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/diffmap",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Serve a spec or code walkthrough as a local page",
5
5
  "license": "MIT",
6
6
  "author": "Tanishq Kancharla",
@@ -15,7 +15,8 @@
15
15
  "bin.js",
16
16
  "index.html",
17
17
  "src",
18
- "vite.config.ts"
18
+ "vite.config.ts",
19
+ "vite.gist.config.ts"
19
20
  ],
20
21
  "type": "module",
21
22
  "exports": {
@@ -33,20 +34,21 @@
33
34
  "lint:fix": "oxlint --fix src vite.config.ts vite.gist.config.ts bin.js",
34
35
  "prepublishOnly": "npm run typecheck && npm run lint && npm run format:check",
35
36
  "release": "npm publish",
36
- "test": "tsx --test src/parseViewer.test.ts src/gist/route.test.ts src/github/route.test.ts src/github/patch.test.ts src/github/fetchRepo.test.ts src/viewer/sourceHeader.test.ts",
37
+ "test": "tsx --test src/cli.test.ts src/update.test.ts src/parseViewer.test.ts src/git.test.ts src/serve.test.ts src/gist/route.test.ts src/github/route.test.ts src/github/patch.test.ts src/github/fetchRepo.test.ts src/viewer/sourceHeader.test.ts src/host/home.test.ts",
37
38
  "typecheck": "tsc --noEmit",
38
39
  "build": "vite build",
39
40
  "build:gist": "vite build",
40
41
  "preview:gist": "vite preview --config vite.gist.config.ts --host 127.0.0.1 --port 4178"
41
42
  },
42
43
  "dependencies": {
43
- "@pierre/diffs": "1.4.1",
44
+ "@pierre/diffs": "1.6.1",
44
45
  "@vitejs/plugin-react": "^6.1.1",
45
46
  "beautiful-mermaid": "^1.1.3",
46
47
  "errore": "^0.14.1",
47
48
  "incur": "^0.4.20",
48
- "maui": "npm:@tanishqkancharla/maui@0.0.16",
49
+ "maui": "npm:@tanishqkancharla/maui@0.0.47",
49
50
  "md4x": "^0.0.29",
51
+ "motion": "^13.4.0",
50
52
  "purse-styles": "^0.2.3",
51
53
  "react": "^19.2.8",
52
54
  "react-dom": "^19.2.8",
@@ -1,20 +1,27 @@
1
- type DiffReference = {
2
- kind: "diff";
3
- id: string;
4
- side: "old" | "new";
5
- start: number;
6
- end: number;
7
- };
1
+ import { isCommitRev, shortRev, type Rev } from "./changes.js";
8
2
 
3
+ /**
4
+ * A file, symbol, or line range. With a commit `rev`, the panel opens that
5
+ * commit's changes and line numbers refer to the file after the commit.
6
+ * Without one, it opens uncommitted changes, or the current file when the
7
+ * file has none.
8
+ */
9
9
  type FileReference = {
10
10
  kind: "file";
11
+ rev?: Rev;
11
12
  path: string;
12
13
  symbol?: string;
13
14
  start?: number;
14
15
  end?: number;
15
16
  };
16
17
 
17
- export type SourceReference = DiffReference | FileReference;
18
+ /** Every file in a rev, such as a changes card's Review button. */
19
+ type ChangesReference = {
20
+ kind: "changes";
21
+ rev: Rev;
22
+ };
23
+
24
+ export type SourceReference = FileReference | ChangesReference;
18
25
 
19
26
  export type SourceAnnotation = {
20
27
  text: string;
@@ -39,41 +46,41 @@ export function parseAnnotation(line: string): SourceAnnotation {
39
46
  return { text, references };
40
47
  }
41
48
 
42
- function parseReference(value: string): SourceReference | undefined {
43
- const diff = /^([\w-]+):(old|new):([1-9]\d*)(?:-([1-9]\d*))?$/.exec(value);
44
- if (diff !== null)
45
- return {
46
- kind: "diff",
47
- id: diff[1]!,
48
- side: diff[2] === "old" ? "old" : "new",
49
- start: Number(diff[3]),
50
- end: Number(diff[4] === undefined ? diff[3] : diff[4]),
51
- };
52
- const file = /^([^#:[\]\n]+)(?:#(.+))?$/.exec(value);
49
+ export function parseReference(value: string): SourceReference | undefined {
50
+ const revMatch = /^([^:#[\]\n]+):(.+)$/.exec(value);
51
+ const rev =
52
+ revMatch !== null && isCommitRev(revMatch[1]!) ? revMatch[1] : undefined;
53
+ const target = rev === undefined ? value : revMatch![2]!;
54
+ const file = /^([^#:[\]\n]+)(?:#(.+))?$/.exec(target);
53
55
  if (file === null || file[1]!.trim().length === 0) return undefined;
54
56
  const path = file[1]!.trim();
55
57
  const fragment = file[2];
56
- if (fragment === undefined) return { kind: "file", path };
58
+ const base =
59
+ rev === undefined
60
+ ? { kind: "file" as const, path }
61
+ : { kind: "file" as const, rev, path };
62
+ if (fragment === undefined) return base;
57
63
  const range = /^L([1-9]\d*)(?:-L?([1-9]\d*))?$/.exec(fragment);
58
64
  if (range !== null)
59
65
  return {
60
- kind: "file",
61
- path,
66
+ ...base,
62
67
  start: Number(range[1]),
63
68
  end: Number(range[2] === undefined ? range[1] : range[2]),
64
69
  };
65
70
  if (!/^[\w$]+(?:\.[\w$]+)*$/.test(fragment)) return undefined;
66
- return { kind: "file", path, symbol: fragment };
71
+ return { ...base, symbol: fragment };
67
72
  }
68
73
 
69
- export function referenceLabel(reference: SourceReference) {
70
- if (reference.kind === "diff")
71
- return `${reference.id} · ${reference.side} ${reference.start}–${reference.end}`;
74
+ /** Short label; pass `basename` for inline prose. */
75
+ export function referenceLabel(reference: SourceReference, basename = false) {
76
+ if (reference.kind === "changes") return shortRev(reference.rev);
77
+ const path = basename ? reference.path.split("/").pop()! : reference.path;
78
+ const rev = reference.rev === undefined ? "" : `${shortRev(reference.rev)}:`;
72
79
  if (reference.symbol !== undefined)
73
- return `${reference.path}#${reference.symbol}`;
80
+ return `${rev}${path}#${reference.symbol}`;
74
81
  if (reference.start !== undefined)
75
- return `${reference.path}#L${reference.start}-L${reference.end}`;
76
- return reference.path;
82
+ return `${rev}${path}#L${reference.start}-L${reference.end}`;
83
+ return `${rev}${path}`;
77
84
  }
78
85
 
79
86
  export type SourceNavigation = {
package/src/changes.ts ADDED
@@ -0,0 +1,229 @@
1
+ import {
2
+ parseDiffFromFile,
3
+ parsePatchFiles,
4
+ type CodeViewDiffItem,
5
+ } from "@pierre/diffs";
6
+
7
+ /**
8
+ * A set of changes the diff panel can show: one commit (`a1b2c3d`), a commit
9
+ * range (`a1b2c3d..e4f5a6b`), uncommitted changes (`working`, `staged`,
10
+ * `unstaged`), a range that ends in them (`a1b2c3d..staged`,
11
+ * `a1b2c3d..working`), or `all` changes the range picker lists.
12
+ */
13
+ export type Rev = string;
14
+
15
+ export const WORKING: Rev = "working";
16
+ export const STAGED: Rev = "staged";
17
+ export const UNSTAGED: Rev = "unstaged";
18
+ export const ALL: Rev = "all";
19
+
20
+ const shaPattern = "[0-9a-f]{7,40}";
21
+ const commitRevPattern = new RegExp(`^${shaPattern}(?:\\.\\.${shaPattern})?$`);
22
+ const revPattern = new RegExp(
23
+ `^(?:working|staged|unstaged|all|${shaPattern}(?:\\.\\.(?:${shaPattern}|staged|working))?)$`,
24
+ );
25
+
26
+ /** Revs a reference can name: commits and commit ranges. */
27
+ export function isCommitRev(value: string) {
28
+ return commitRevPattern.test(value);
29
+ }
30
+
31
+ export function isRev(value: string) {
32
+ return revPattern.test(value);
33
+ }
34
+
35
+ export function shortRev(rev: Rev) {
36
+ if (rev === WORKING) return "Uncommitted";
37
+ if (rev === STAGED) return "Staged";
38
+ if (rev === UNSTAGED) return "Unstaged";
39
+ if (rev === ALL) return "All commits";
40
+ return rev
41
+ .split("..")
42
+ .map((part) =>
43
+ part === STAGED || part === WORKING ? part : part.slice(0, 7),
44
+ )
45
+ .join("..");
46
+ }
47
+
48
+ /** One row of the range picker, newest first: unstaged, staged, then commits. */
49
+ export type Delta =
50
+ | { kind: "unstaged" | "staged"; additions: number; deletions: number }
51
+ | {
52
+ kind: "commit";
53
+ sha: string;
54
+ /** First parent, or the empty tree for a root commit. */
55
+ parent: string;
56
+ subject: string;
57
+ additions: number;
58
+ deletions: number;
59
+ };
60
+
61
+ export type DeltasResponse = { deltas?: Delta[]; error?: string };
62
+
63
+ /** Picked rows, as indexes into the delta list: `newest <= oldest`. */
64
+ export type DeltaWindow = { newest: number; oldest: number };
65
+
66
+ /**
67
+ * The rev for picked rows. A window is always explicit, even when it covers
68
+ * every row: `all` is resolved against the default list, which may be shorter
69
+ * than one extended to reach an older rev.
70
+ */
71
+ export function windowRev(deltas: Delta[], window: DeltaWindow): Rev {
72
+ const newest = deltas[window.newest]!;
73
+ const oldest = deltas[window.oldest]!;
74
+ if (window.newest === window.oldest)
75
+ return newest.kind === "commit" ? newest.sha : newest.kind;
76
+ // Only unstaged through staged: everything uncommitted.
77
+ if (oldest.kind !== "commit") return WORKING;
78
+ const head =
79
+ newest.kind === "commit"
80
+ ? newest.sha
81
+ : newest.kind === "staged"
82
+ ? STAGED
83
+ : WORKING;
84
+ return `${oldest.parent}..${head}`;
85
+ }
86
+
87
+ const same = (full: string, short: string) => full.startsWith(short);
88
+
89
+ /** The rows a rev covers, or undefined when the list cannot show it. */
90
+ export function revWindow(deltas: Delta[], rev: Rev): DeltaWindow | undefined {
91
+ if (rev === ALL) return { newest: 0, oldest: deltas.length - 1 };
92
+ if (rev === UNSTAGED) return { newest: 0, oldest: 0 };
93
+ if (rev === STAGED) return { newest: 1, oldest: 1 };
94
+ if (rev === WORKING) return { newest: 0, oldest: 1 };
95
+ const [first, second] = rev.split("..");
96
+ if (second === undefined) {
97
+ const index = deltas.findIndex(
98
+ (delta) => delta.kind === "commit" && same(delta.sha, first!),
99
+ );
100
+ return index === -1 ? undefined : { newest: index, oldest: index };
101
+ }
102
+ const oldest = deltas.findIndex(
103
+ (delta) => delta.kind === "commit" && same(delta.parent, first!),
104
+ );
105
+ const newest =
106
+ second === WORKING
107
+ ? 0
108
+ : second === STAGED
109
+ ? 1
110
+ : deltas.findIndex(
111
+ (delta) => delta.kind === "commit" && same(delta.sha, second),
112
+ );
113
+ if (oldest === -1 || newest === -1 || newest > oldest) return undefined;
114
+ return { newest, oldest };
115
+ }
116
+
117
+ function deltaName(delta: Delta) {
118
+ if (delta.kind === "commit") return delta.sha.slice(0, 7);
119
+ return delta.kind === "staged" ? "Staged" : "Unstaged";
120
+ }
121
+
122
+ /** "All commits", one commit's title, Unstaged or Staged, or `<oldest>…<newest>` for a range. */
123
+ export function windowLabel(deltas: Delta[], window: DeltaWindow) {
124
+ if (window.newest === 0 && window.oldest === deltas.length - 1)
125
+ return "All commits";
126
+ const newest = deltas[window.newest]!;
127
+ if (window.newest === window.oldest)
128
+ return newest.kind === "commit" ? newest.subject : deltaName(newest);
129
+ return `${deltaName(deltas[window.oldest]!)}…${deltaName(newest)}`;
130
+ }
131
+
132
+ export type ChangedFile = {
133
+ path: string;
134
+ /** Present when git detected a rename. */
135
+ prevPath?: string;
136
+ status: "added" | "deleted" | "modified" | "renamed";
137
+ additions: number;
138
+ deletions: number;
139
+ binary: boolean;
140
+ /**
141
+ * Full contents make every line reachable. Hosted GitHub viewers only get
142
+ * the hunk patch from the API.
143
+ */
144
+ /** A missing side means the file did not exist there. */
145
+ contents?: { old?: string; new?: string };
146
+ patch?: string;
147
+ };
148
+
149
+ export type Changes = {
150
+ rev: Rev;
151
+ title: string;
152
+ /** Web page for these changes, such as a GitHub commit. */
153
+ url?: string;
154
+ /** GitHub listed only the first files; `url` has the rest. */
155
+ truncated?: boolean;
156
+ files: ChangedFile[];
157
+ };
158
+
159
+ /** Gist file holding the changes a shared spec references, keyed by rev. */
160
+ export const GIST_CHANGES_FILE = "diffmap-changes.json";
161
+
162
+ export type ChangesResponse = { changes?: Changes; error?: string };
163
+
164
+ /** jsdiff's default lines of context around each change. */
165
+ const CONTEXT = 4;
166
+
167
+ /**
168
+ * Binary files have neither contents nor a patch, so they have no diff item.
169
+ * With `focus`, the diff keeps enough unchanged context to show those new-side
170
+ * lines; its version is the context it needed.
171
+ */
172
+ export function changedFileItem(
173
+ file: ChangedFile,
174
+ focus?: { start: number; end: number },
175
+ ): CodeViewDiffItem | undefined {
176
+ if (file.contents === undefined) {
177
+ const fileDiff =
178
+ file.patch === undefined
179
+ ? undefined
180
+ : parsePatchFiles(file.patch, undefined, false)[0]?.files[0];
181
+ return fileDiff === undefined
182
+ ? undefined
183
+ : { id: file.path, type: "diff", fileDiff };
184
+ }
185
+ const { old, new: next } = file.contents;
186
+ const parse = (context: number) =>
187
+ parseDiffFromFile(
188
+ old === undefined
189
+ ? // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a missing side.
190
+ null
191
+ : { name: file.prevPath ?? file.path, contents: old },
192
+ next === undefined
193
+ ? // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a missing side.
194
+ null
195
+ : { name: file.path, contents: next },
196
+ { context },
197
+ );
198
+ let context = CONTEXT;
199
+ let fileDiff = parse(context);
200
+ const lines = next === undefined ? 0 : next.split("\n").length;
201
+ if (focus !== undefined) {
202
+ while (context < lines && !shows(fileDiff.hunks, focus)) {
203
+ context *= 4;
204
+ fileDiff = parse(context);
205
+ }
206
+ }
207
+ return {
208
+ id: file.path,
209
+ type: "diff",
210
+ fileDiff,
211
+ ...(context === CONTEXT ? {} : { version: context }),
212
+ };
213
+ }
214
+
215
+ /** Whether every focused new-side line falls inside a hunk, rather than a collapsed gap. */
216
+ function shows(
217
+ hunks: { additionStart: number; additionCount: number }[],
218
+ focus: { start: number; end: number },
219
+ ) {
220
+ for (let line = focus.start; line <= focus.end; line++) {
221
+ const inside = hunks.some(
222
+ (hunk) =>
223
+ line >= hunk.additionStart &&
224
+ line < hunk.additionStart + hunk.additionCount,
225
+ );
226
+ if (!inside) return false;
227
+ }
228
+ return true;
229
+ }
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const srcDir = fileURLToPath(new URL(".", import.meta.url));
11
+ const packageRoot = path.resolve(srcDir, "..");
12
+ const cliPath = path.join(srcDir, "cli.ts");
13
+
14
+ async function runCli(args: string[]) {
15
+ try {
16
+ const result = await execFileAsync(
17
+ process.execPath,
18
+ ["--import", "tsx", cliPath, ...args],
19
+ {
20
+ cwd: packageRoot,
21
+ timeout: 20_000,
22
+ maxBuffer: 1_000_000,
23
+ },
24
+ );
25
+ return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
26
+ } catch (cause) {
27
+ if (typeof cause !== "object" || !cause) throw cause;
28
+ const exitCode =
29
+ "code" in cause && typeof cause.code === "number"
30
+ ? cause.code
31
+ : undefined;
32
+ const stdout =
33
+ "stdout" in cause && typeof cause.stdout === "string" ? cause.stdout : "";
34
+ const stderr =
35
+ "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr : "";
36
+ return { exitCode, stdout, stderr };
37
+ }
38
+ }
39
+
40
+ test("diffmap --version matches package.json", async () => {
41
+ const pkg = JSON.parse(
42
+ await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
43
+ ) as { version: string };
44
+ const source = await fs.readFile(cliPath, "utf8");
45
+ assert.doesNotMatch(source, /version:\s*["'`]\d+\.\d+\.\d+/);
46
+ const result = await runCli(["--version"]);
47
+ assert.equal(result.exitCode, 0, result.stderr);
48
+ const printed = `${result.stdout}${result.stderr}`.trim();
49
+ assert.equal(printed, pkg.version);
50
+ });