@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/README.md
CHANGED
|
@@ -21,7 +21,7 @@ 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`.
|
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
} from "@tanishqkancharla/diffmap";
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
`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.
|
|
72
72
|
|
|
73
73
|
## Fences
|
|
74
74
|
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
| `callstack` or `diff` containing `└──` / `├──` | Interactive stack rows, no file header |
|
|
79
79
|
| `diff` or `diff:path` with a file path | Pierre patch with Pierre’s file header |
|
|
80
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 |
|
|
81
82
|
| `start:end:path` | Pierre file excerpt with Pierre’s file header |
|
|
82
83
|
| `html` | Trusted HTML from this file. diffmap does not sanitize it. Only use it for local files you wrote. |
|
|
83
84
|
| other langs | Maui `CodeBlock` |
|
|
@@ -107,6 +108,9 @@ startServer
|
|
|
107
108
|
::file{path="src/cli.ts" start="1" end="20"}
|
|
108
109
|
::
|
|
109
110
|
|
|
111
|
+
::changes{rev="a1b2c3d"}
|
|
112
|
+
::
|
|
113
|
+
|
|
110
114
|
::html
|
|
111
115
|
<aside>Note.</aside>
|
|
112
116
|
::
|
|
@@ -116,58 +120,43 @@ startServer
|
|
|
116
120
|
|
|
117
121
|
## Link call stacks to source changes
|
|
118
122
|
|
|
119
|
-
Append
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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>`.
|
|
123
133
|
|
|
124
134
|
```callstack
|
|
125
|
-
|
|
126
|
-
+└──
|
|
135
|
+
shareMarkdownFile [[src/share.ts#shareMarkdownFile]]
|
|
136
|
+
+└── gistViewerUrl # print the diffmap.dev link [[d14ae1a:src/share.ts#L55-L59]]
|
|
127
137
|
```
|
|
128
138
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
require a `source-diff` fence. Use `[[path/to/file]]` for a whole file or
|
|
133
|
-
`[[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.
|
|
134
142
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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`.
|
|
138
149
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
line can use `[[id:new:12]]`. Multiple references on one stack line are allowed.
|
|
142
|
-
The references are hidden in the rendered stack, and linked rows support mouse
|
|
143
|
-
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.
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
range must fit within one included hunk. Use the new path for renamed files and
|
|
149
|
-
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.
|
|
150
156
|
|
|
151
157
|
````md
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
-└── saveUnchecked [[request:old:12]]
|
|
155
|
-
+├── validateInput # reject invalid input [[request:new:12-13]]
|
|
156
|
-
└── saveRecord
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
```source-diff:request:src/request.ts
|
|
160
|
-
diff --git a/src/request.ts b/src/request.ts
|
|
161
|
-
--- a/src/request.ts
|
|
162
|
-
+++ b/src/request.ts
|
|
163
|
-
@@ -10,5 +10,6 @@
|
|
164
|
-
export function handleRequest(input: Input) {
|
|
165
|
-
const record = input.record;
|
|
166
|
-
- saveUnchecked(record);
|
|
167
|
-
+ const error = validateInput(record);
|
|
168
|
-
+ if (error instanceof Error) return error;
|
|
169
|
-
return saveRecord(record);
|
|
170
|
-
}
|
|
158
|
+
```changes
|
|
159
|
+
a1b2c3d
|
|
171
160
|
```
|
|
172
161
|
````
|
|
173
162
|
|
|
@@ -175,36 +164,45 @@ References open in one shared panel beside the walkthrough. Selecting a stack
|
|
|
175
164
|
line or diagram element highlights it and scrolls the panel to the linked code
|
|
176
165
|
range. When a target has several references, buttons above the source viewer
|
|
177
166
|
select among them. On narrow windows the source panel sits below the walkthrough.
|
|
178
|
-
Documents without references
|
|
167
|
+
Documents without references keep the single-panel layout.
|
|
168
|
+
|
|
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.
|
|
179
174
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
175
|
+
A shared gist is a snapshot taken when you run `diffmap share`. It saves every
|
|
176
|
+
referenced commit and every `changes` card, including uncommitted ones, to
|
|
177
|
+
`diffmap-changes.json`. It saves the current contents of every file that a file
|
|
178
|
+
excerpt or a reference without a commit reads to `diffmap-files.json`. In the
|
|
179
|
+
gist, those references open the saved file; symbols resolve to their first
|
|
180
|
+
matching line, and Cmd-click definitions only work in a local viewer. Commit
|
|
181
|
+
your work and point references at the commit before sharing, so the gist shows
|
|
182
|
+
the diff instead of a bare file.
|
|
183
183
|
|
|
184
184
|
Cmd-click (or Ctrl-click) a TypeScript or JavaScript symbol in the source panel
|
|
185
185
|
to open its definition. Use Back to retrace navigation and return to the reference.
|
|
186
186
|
Resolution uses the workspace's TypeScript configuration and current files.
|
|
187
|
-
Deleted files and lines that no longer match the workspace show a message;
|
|
188
|
-
their call stack references still highlight the embedded old-side diff.
|
|
189
187
|
|
|
190
188
|
Run `npx @tanishqkancharla/diffmap fixtures/annotations.md` for an example
|
|
191
|
-
with
|
|
189
|
+
with commit references, multiple files, and unchanged context.
|
|
192
190
|
|
|
193
191
|
## Link Mermaid nodes and edges
|
|
194
192
|
|
|
195
193
|
Inside a Mermaid fence, add `%% ref node:<id> [[reference]]` to link a node or
|
|
196
194
|
participant, or `%% ref edge:<index> [[reference]]` to link an edge or sequence
|
|
197
195
|
message. Edge indices start at zero in declaration order. The same file, symbol,
|
|
198
|
-
and
|
|
196
|
+
and commit references work here. Keep one directive per target; append
|
|
199
197
|
multiple references to that directive when needed.
|
|
200
198
|
|
|
201
199
|
````md
|
|
202
200
|
```mermaid
|
|
203
201
|
flowchart LR
|
|
204
|
-
A[
|
|
205
|
-
%% ref node:A [[src/
|
|
206
|
-
%% ref node:B [[src/
|
|
207
|
-
%% ref edge:0 [[
|
|
202
|
+
A[ViewerApp] -->|Local mode only| B[CloseServerButton]
|
|
203
|
+
%% ref node:A [[src/viewer/ViewerApp.tsx#ViewerApp]]
|
|
204
|
+
%% ref node:B [[dae6b98:src/viewer/CloseServerButton.tsx#L8-L16]]
|
|
205
|
+
%% ref edge:0 [[dae6b98:src/viewer/ViewerApp.tsx#L219-L227]]
|
|
208
206
|
```
|
|
209
207
|
|
|
210
208
|
```mermaid
|
|
@@ -221,3 +219,12 @@ Click a linked shape, edge line, or edge label, or focus it with Tab and press
|
|
|
221
219
|
Enter or Space. Directives are Mermaid comments, so they remain hidden and do
|
|
222
220
|
not change the diagram when rendered by other Mermaid tools. Missing diagram
|
|
223
221
|
targets display an error. See [the runnable example](fixtures/references.md).
|
|
222
|
+
|
|
223
|
+
## Link from prose
|
|
224
|
+
|
|
225
|
+
Write any reference in a paragraph, list item, or table cell, such as
|
|
226
|
+
`See [[src/store.ts#Store.save]] for the write path.` or
|
|
227
|
+
`Close server landed in [[dae6b98:src/viewer/CloseServerButton.tsx]].` It
|
|
228
|
+
renders as clickable inline code labeled with the file name and fragment
|
|
229
|
+
(`store.ts#Store.save`) and opens the same source panel. References inside code spans and link text stay
|
|
230
|
+
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.3.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/cli.test.ts src/update.test.ts src/parseViewer.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
|
+
"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,235 @@
|
|
|
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
|
+
/**
|
|
163
|
+
* Gist file holding the current contents of files a shared spec reads live,
|
|
164
|
+
* keyed by path: file excerpts and references without a commit.
|
|
165
|
+
*/
|
|
166
|
+
export const GIST_FILES_FILE = "diffmap-files.json";
|
|
167
|
+
|
|
168
|
+
export type ChangesResponse = { changes?: Changes; error?: string };
|
|
169
|
+
|
|
170
|
+
/** jsdiff's default lines of context around each change. */
|
|
171
|
+
const CONTEXT = 4;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Binary files have neither contents nor a patch, so they have no diff item.
|
|
175
|
+
* With `focus`, the diff keeps enough unchanged context to show those new-side
|
|
176
|
+
* lines; its version is the context it needed.
|
|
177
|
+
*/
|
|
178
|
+
export function changedFileItem(
|
|
179
|
+
file: ChangedFile,
|
|
180
|
+
focus?: { start: number; end: number },
|
|
181
|
+
): CodeViewDiffItem | undefined {
|
|
182
|
+
if (file.contents === undefined) {
|
|
183
|
+
const fileDiff =
|
|
184
|
+
file.patch === undefined
|
|
185
|
+
? undefined
|
|
186
|
+
: parsePatchFiles(file.patch, undefined, false)[0]?.files[0];
|
|
187
|
+
return fileDiff === undefined
|
|
188
|
+
? undefined
|
|
189
|
+
: { id: file.path, type: "diff", fileDiff };
|
|
190
|
+
}
|
|
191
|
+
const { old, new: next } = file.contents;
|
|
192
|
+
const parse = (context: number) =>
|
|
193
|
+
parseDiffFromFile(
|
|
194
|
+
old === undefined
|
|
195
|
+
? // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a missing side.
|
|
196
|
+
null
|
|
197
|
+
: { name: file.prevPath ?? file.path, contents: old },
|
|
198
|
+
next === undefined
|
|
199
|
+
? // oxlint-disable-next-line unicorn/no-null -- Pierre uses null for a missing side.
|
|
200
|
+
null
|
|
201
|
+
: { name: file.path, contents: next },
|
|
202
|
+
{ context },
|
|
203
|
+
);
|
|
204
|
+
let context = CONTEXT;
|
|
205
|
+
let fileDiff = parse(context);
|
|
206
|
+
const lines = next === undefined ? 0 : next.split("\n").length;
|
|
207
|
+
if (focus !== undefined) {
|
|
208
|
+
while (context < lines && !shows(fileDiff.hunks, focus)) {
|
|
209
|
+
context *= 4;
|
|
210
|
+
fileDiff = parse(context);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
id: file.path,
|
|
215
|
+
type: "diff",
|
|
216
|
+
fileDiff,
|
|
217
|
+
...(context === CONTEXT ? {} : { version: context }),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Whether every focused new-side line falls inside a hunk, rather than a collapsed gap. */
|
|
222
|
+
function shows(
|
|
223
|
+
hunks: { additionStart: number; additionCount: number }[],
|
|
224
|
+
focus: { start: number; end: number },
|
|
225
|
+
) {
|
|
226
|
+
for (let line = focus.start; line <= focus.end; line++) {
|
|
227
|
+
const inside = hunks.some(
|
|
228
|
+
(hunk) =>
|
|
229
|
+
line >= hunk.additionStart &&
|
|
230
|
+
line < hunk.additionStart + hunk.additionCount,
|
|
231
|
+
);
|
|
232
|
+
if (!inside) return false;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -105,6 +105,7 @@ const cli = Cli.create("diffmap", {
|
|
|
105
105
|
async run(c) {
|
|
106
106
|
const shared = await shareMarkdownFile({
|
|
107
107
|
filePath: resolveFromInvokeCwd(c.args.file),
|
|
108
|
+
workspaceRoot: resolveFromInvokeCwd("."),
|
|
108
109
|
isPublic: c.options.public === true,
|
|
109
110
|
});
|
|
110
111
|
if (shared instanceof Error) {
|
package/src/errors.ts
CHANGED
|
@@ -30,6 +30,11 @@ export class DiffmapAnnotationError extends errore.createTaggedError({
|
|
|
30
30
|
message: "diffmap source annotation: $reason",
|
|
31
31
|
}) {}
|
|
32
32
|
|
|
33
|
+
export class DiffmapGitError extends errore.createTaggedError({
|
|
34
|
+
name: "DiffmapGitError",
|
|
35
|
+
message: "$reason",
|
|
36
|
+
}) {}
|
|
37
|
+
|
|
33
38
|
export class DiffmapDefinitionError extends errore.createTaggedError({
|
|
34
39
|
name: "DiffmapDefinitionError",
|
|
35
40
|
message: "$reason",
|