@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.
Files changed (42) hide show
  1. package/README.md +64 -57
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +235 -0
  6. package/src/cli.ts +1 -0
  7. package/src/errors.ts +5 -0
  8. package/src/gist/GistApp.tsx +38 -17
  9. package/src/gist/fetchGist.ts +47 -0
  10. package/src/git.test.ts +402 -0
  11. package/src/git.ts +498 -0
  12. package/src/github/GitHubApp.tsx +39 -22
  13. package/src/github/fetchRepo.test.ts +59 -1
  14. package/src/github/fetchRepo.ts +99 -43
  15. package/src/github/patch.test.ts +18 -9
  16. package/src/github/patch.ts +30 -19
  17. package/src/host/HostHome.tsx +20 -7
  18. package/src/host/files.ts +5 -1
  19. package/src/host/home.md +17 -55
  20. package/src/host/home.test.ts +4 -3
  21. package/src/index.ts +1 -0
  22. package/src/parseFence.ts +6 -6
  23. package/src/parseViewer.test.ts +140 -0
  24. package/src/parseViewer.ts +104 -78
  25. package/src/serve.ts +34 -1
  26. package/src/share.ts +62 -1
  27. package/src/viewer/CallStackDiff.tsx +15 -3
  28. package/src/viewer/ChangesCard.tsx +267 -0
  29. package/src/viewer/ComarkView.tsx +83 -15
  30. package/src/viewer/Fence.tsx +9 -1
  31. package/src/viewer/MermaidBlock.tsx +99 -11
  32. package/src/viewer/RangePicker.tsx +526 -0
  33. package/src/viewer/SourceDiffPanel.tsx +254 -116
  34. package/src/viewer/TableOfContents.tsx +177 -93
  35. package/src/viewer/ViewerApp.tsx +34 -114
  36. package/src/viewer/changesSource.ts +91 -0
  37. package/src/viewer/sourceHeader.test.ts +2 -87
  38. package/src/viewer/sourceHeader.ts +4 -63
  39. package/src/viewer/sourceNavigation.ts +0 -43
  40. package/vite.config.ts +1 -0
  41. package/vite.gist.config.ts +57 -0
  42. 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-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`.
@@ -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-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.
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 `[[path/to/file.ts#symbolName]]` to a call stack line to link a symbol.
120
- Paths are relative to `--root`. Symbol lookup supports TypeScript and JavaScript
121
- declarations, including qualified names such as `[[src/store.ts#Store.save]]`.
122
- 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>`.
123
133
 
124
134
  ```callstack
125
- handleRequest [[src/request.ts#handleRequest]]
126
- +└── 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]]
127
137
  ```
128
138
 
129
- The panel highlights the declaration in the current workspace file. If an
130
- included source patch overlaps that declaration and its new-side text matches
131
- the workspace, the panel highlights the patch instead. Symbol references do not
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
- To reference an exact change, including removed code, use the patch syntax below.
136
- Symbol names resolve against current files; use an `old` reference for a deleted
137
- 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`.
138
149
 
139
- Append `[[id:side:start-end]]` to a call stack line. `side` is `old` or `new`;
140
- line numbers refer to that version of the source file, not the patch. A single
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
- Define each ID once in a `source-diff:id:path` fence anywhere in the document.
146
- Copy the file's actual Git patch, including its `diff --git`, file headers, and
147
- `@@` hunk headers. Keep enough context to explain the change. Each reference
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
- ```callstack
153
- handleRequest
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 or source patches keep the single-panel layout.
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
- Unknown IDs, duplicate definitions, malformed references, mismatched paths, and
181
- ranges outside the included hunks produce a parse error. Source patches are
182
- embedded snapshots; the viewer does not regenerate them from the working tree.
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 old/new references, multiple files, and unchanged context.
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 old/new patch references work here. Keep one directive per target; append
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[Request] -->|Validate| B[Validation]
205
- %% ref node:A [[src/request.ts#handleRequest]]
206
- %% ref node:B [[src/validation.ts#validateInput]]
207
- %% ref edge:0 [[request:new:12-13]]
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.1.5",
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.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.30",
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,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",