@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.
- package/README.md +69 -61
- package/index.html +18 -0
- package/package.json +7 -5
- package/src/annotations.ts +36 -29
- package/src/changes.ts +229 -0
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +41 -5
- package/src/errors.ts +10 -0
- package/src/gist/GistApp.tsx +39 -20
- package/src/gist/GistStatus.tsx +2 -51
- package/src/gist/fetchGist.ts +23 -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 +86 -0
- package/src/host/files.ts +10 -0
- package/src/host/home.md +156 -0
- package/src/host/home.test.ts +83 -0
- package/src/index.ts +1 -0
- package/src/packageVersion.ts +7 -0
- package/src/parseFence.ts +6 -6
- package/src/parseViewer.test.ts +87 -0
- package/src/parseViewer.ts +103 -77
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +48 -5
- package/src/share.ts +18 -0
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CallStackDiff.tsx +15 -3
- package/src/viewer/ChangesCard.tsx +267 -0
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/ComarkView.tsx +83 -15
- package/src/viewer/Fence.tsx +9 -1
- package/src/viewer/FileExcerpt.tsx +25 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/MermaidBlock.tsx +99 -11
- package/src/viewer/RangePicker.tsx +526 -0
- package/src/viewer/SourceDiffPanel.tsx +277 -121
- package/src/viewer/TableOfContents.tsx +313 -39
- package/src/viewer/ViewerApp.tsx +120 -68
- 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 +33 -42
- package/src/viewer/viewerMode.ts +1 -1
- package/vite.config.ts +2 -1
- package/vite.gist.config.ts +57 -0
- package/src/viewer/Check.tsx +0 -24
- 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-
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
+└──
|
|
135
|
+
shareMarkdownFile [[src/share.ts#shareMarkdownFile]]
|
|
136
|
+
+└── gistViewerUrl # print the diffmap.dev link [[d14ae1a:src/share.ts#L55-L59]]
|
|
119
137
|
```
|
|
120
138
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
```
|
|
145
|
-
|
|
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
|
|
167
|
+
Documents without references keep the single-panel layout.
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
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[
|
|
197
|
-
%% ref node:A [[src/
|
|
198
|
-
%% ref node:B [[src/
|
|
199
|
-
%% ref edge:0 [[
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/annotations.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
type
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
71
|
+
return { ...base, symbol: fragment };
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 `${
|
|
80
|
+
return `${rev}${path}#${reference.symbol}`;
|
|
74
81
|
if (reference.start !== undefined)
|
|
75
|
-
return `${
|
|
76
|
-
return
|
|
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
|
+
}
|
package/src/cli.test.ts
ADDED
|
@@ -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
|
+
});
|