@tanishqkancharla/diffmap 0.1.3 → 0.1.5

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 (50) hide show
  1. package/README.md +25 -14
  2. package/package.json +3 -3
  3. package/src/cli.test.ts +50 -0
  4. package/src/cli.ts +126 -51
  5. package/src/contentPlugin.ts +5 -3
  6. package/src/errors.ts +6 -1
  7. package/src/gist/GistApp.tsx +34 -21
  8. package/src/gist/GistStatus.tsx +8 -43
  9. package/src/gist/main.tsx +2 -2
  10. package/src/gist/pin.ts +14 -0
  11. package/src/github/GitHubApp.tsx +235 -0
  12. package/src/github/fetchRepo.test.ts +17 -0
  13. package/src/github/fetchRepo.ts +300 -0
  14. package/src/github/patch.test.ts +30 -0
  15. package/src/github/patch.ts +40 -0
  16. package/src/github/pin.ts +14 -0
  17. package/src/github/route.test.ts +68 -0
  18. package/src/github/route.ts +175 -0
  19. package/src/host/HostApp.tsx +22 -0
  20. package/src/host/HostHome.tsx +73 -0
  21. package/src/host/files.ts +10 -0
  22. package/src/host/home.md +194 -0
  23. package/src/host/home.test.ts +82 -0
  24. package/src/index.ts +1 -0
  25. package/src/mermaid.ts +29 -1
  26. package/src/packageVersion.ts +7 -0
  27. package/src/parseViewer.test.ts +185 -0
  28. package/src/parseViewer.ts +27 -3
  29. package/src/serve.test.ts +306 -0
  30. package/src/serve.ts +23 -5
  31. package/src/share.ts +5 -1
  32. package/src/update.test.ts +19 -0
  33. package/src/update.ts +285 -0
  34. package/src/viewer/CallStackDiff.tsx +26 -6
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/FileExcerpt.tsx +52 -3
  37. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  38. package/src/viewer/SourceDiffPanel.tsx +155 -59
  39. package/src/viewer/TableOfContents.tsx +230 -39
  40. package/src/viewer/TocButton.tsx +27 -0
  41. package/src/viewer/ViewerApp.tsx +182 -47
  42. package/src/viewer/main.tsx +10 -3
  43. package/src/viewer/sourceHeader.test.ts +131 -0
  44. package/src/viewer/sourceHeader.ts +103 -0
  45. package/src/viewer/sourceNavigation.ts +81 -3
  46. package/src/viewer/viewerMode.ts +1 -1
  47. package/src/viewer/vite-env.d.ts +2 -1
  48. package/vite.config.ts +1 -1
  49. package/src/viewer/Check.tsx +0 -24
  50. package/src/viewer/DoneButton.tsx +0 -32
package/src/update.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { DiffmapUpdateError } from "./errors.js";
4
+
5
+ // The unscoped `diffmap` package on npm is unrelated; always use the scoped name.
6
+ export const packageName = "@tanishqkancharla/diffmap";
7
+ const latestSpec = `${packageName}@latest`;
8
+ const registryUrl =
9
+ "https://registry.npmjs.org/@tanishqkancharla%2Fdiffmap/latest";
10
+
11
+ type InstallMethod = "npm" | "pnpm" | "bun" | "npx" | "unknown";
12
+
13
+ type Command = { command: string; args: string[] };
14
+
15
+ type UpdateResult =
16
+ | { status: "up-to-date"; current: string; latest: string; message: string }
17
+ | {
18
+ status: "updated";
19
+ previous: string;
20
+ current: string;
21
+ command: string;
22
+ message: string;
23
+ }
24
+ | {
25
+ status: "manual";
26
+ current: string;
27
+ latest: string;
28
+ command: string;
29
+ message: string;
30
+ };
31
+
32
+ type UpdateDeps = {
33
+ currentVersion: string;
34
+ fetchLatestVersion: () => Promise<string | Error>;
35
+ detectInstallMethod: () => Promise<InstallMethod>;
36
+ runCommand: (command: Command) => Promise<void | Error>;
37
+ };
38
+
39
+ /** Routes `diffmap --update [...]` to the `update` command so incur's built-in updater never runs. */
40
+ export function resolveUpdateArgv(argv: string[]) {
41
+ const separator = argv.indexOf("--");
42
+ const flags = separator === -1 ? argv : argv.slice(0, separator);
43
+ if (!flags.includes("--update")) return argv;
44
+ const rest = argv.filter((token, index) => {
45
+ return token !== "--update" || (separator !== -1 && index > separator);
46
+ });
47
+ return ["update", ...rest];
48
+ }
49
+
50
+ type ParsedVersion = {
51
+ core: [number, number, number];
52
+ prerelease: string[];
53
+ };
54
+
55
+ function parseVersion(version: string): ParsedVersion | undefined {
56
+ const match = version
57
+ .trim()
58
+ .replace(/^v/, "")
59
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
60
+ if (!match) return undefined;
61
+ return {
62
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
63
+ prerelease: match[4] === undefined ? [] : match[4].split("."),
64
+ };
65
+ }
66
+
67
+ /** Semver precedence: negative when `a < b`, positive when `a > b`, 0 when equal. */
68
+ export function compareVersions(a: string, b: string) {
69
+ const left = parseVersion(a);
70
+ const right = parseVersion(b);
71
+ if (!left || !right) {
72
+ return new DiffmapUpdateError({
73
+ reason: `cannot compare versions "${a}" and "${b}"`,
74
+ });
75
+ }
76
+ for (let index = 0; index < 3; index++) {
77
+ const difference = left.core[index]! - right.core[index]!;
78
+ if (difference !== 0) return Math.sign(difference);
79
+ }
80
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
81
+ return Math.sign(right.prerelease.length - left.prerelease.length);
82
+ }
83
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
84
+ for (let index = 0; index < length; index++) {
85
+ const leftPart = left.prerelease[index];
86
+ const rightPart = right.prerelease[index];
87
+ if (leftPart === undefined) return -1;
88
+ if (rightPart === undefined) return 1;
89
+ if (leftPart === rightPart) continue;
90
+ const leftNumeric = /^\d+$/.test(leftPart);
91
+ const rightNumeric = /^\d+$/.test(rightPart);
92
+ if (leftNumeric && rightNumeric) {
93
+ return Math.sign(Number(leftPart) - Number(rightPart));
94
+ }
95
+ if (leftNumeric) return -1;
96
+ if (rightNumeric) return 1;
97
+ return leftPart < rightPart ? -1 : 1;
98
+ }
99
+ return 0;
100
+ }
101
+
102
+ function normalizePath(value: string) {
103
+ return value.replaceAll("\\", "/").replace(/\/+$/, "");
104
+ }
105
+
106
+ /** Infers the install method from the real path of the running entry script. */
107
+ function detectInstallMethodFromPath(input: {
108
+ entryPath: string;
109
+ env: Record<string, string | undefined>;
110
+ npmGlobalRoot?: string | undefined;
111
+ }): InstallMethod {
112
+ const entry = normalizePath(input.entryPath);
113
+ if (input.env.npm_command === "exec" || entry.includes("/_npx/")) {
114
+ return "npx";
115
+ }
116
+ if (entry.includes("/.bun/install/global/")) return "bun";
117
+ const pnpmHome = input.env.PNPM_HOME;
118
+ if (
119
+ entry.includes("/pnpm/global/") ||
120
+ (pnpmHome !== undefined &&
121
+ pnpmHome !== "" &&
122
+ entry.startsWith(`${normalizePath(pnpmHome)}/global/`))
123
+ ) {
124
+ return "pnpm";
125
+ }
126
+ if (input.npmGlobalRoot !== undefined && input.npmGlobalRoot !== "") {
127
+ const root = normalizePath(input.npmGlobalRoot);
128
+ if (entry.startsWith(`${root}/${packageName}/`)) return "npm";
129
+ }
130
+ return "unknown";
131
+ }
132
+
133
+ function globalInstallCommand(method: "npm" | "pnpm" | "bun"): Command {
134
+ if (method === "pnpm") {
135
+ return { command: "pnpm", args: ["add", "--global", latestSpec] };
136
+ }
137
+ if (method === "bun") {
138
+ return { command: "bun", args: ["add", "--global", latestSpec] };
139
+ }
140
+ return { command: "npm", args: ["install", "--global", latestSpec] };
141
+ }
142
+
143
+ function formatCommand(command: Command) {
144
+ return [command.command, ...command.args].join(" ");
145
+ }
146
+
147
+ export async function runUpdate(deps: UpdateDeps) {
148
+ const current = deps.currentVersion;
149
+ const latest = await deps.fetchLatestVersion();
150
+ if (latest instanceof Error) return latest;
151
+
152
+ const comparison = compareVersions(current, latest);
153
+ if (comparison instanceof Error) return comparison;
154
+ if (comparison >= 0) {
155
+ return {
156
+ message: `diffmap is already up to date (${current})`,
157
+ status: "up-to-date",
158
+ current,
159
+ latest,
160
+ } satisfies UpdateResult;
161
+ }
162
+
163
+ const method = await deps.detectInstallMethod();
164
+ if (method === "npx") {
165
+ const command = `npx ${latestSpec}`;
166
+ return {
167
+ message: `diffmap ${latest} is available (running ${current} through npx). Run \`${command}\` to use it, or install it globally with \`${formatCommand(globalInstallCommand("npm"))}\`.`,
168
+ status: "manual",
169
+ current,
170
+ latest,
171
+ command,
172
+ } satisfies UpdateResult;
173
+ }
174
+ if (method === "unknown") {
175
+ const command = formatCommand(globalInstallCommand("npm"));
176
+ return {
177
+ message: `diffmap ${latest} is available (running ${current}), but diffmap could not tell how it was installed. Update it with \`${command}\` or your package manager's equivalent.`,
178
+ status: "manual",
179
+ current,
180
+ latest,
181
+ command,
182
+ } satisfies UpdateResult;
183
+ }
184
+
185
+ const install = globalInstallCommand(method);
186
+ const installed = await deps.runCommand(install);
187
+ if (installed instanceof Error) return installed;
188
+ return {
189
+ message: `Updated diffmap from ${current} to ${latest}`,
190
+ status: "updated",
191
+ previous: current,
192
+ current: latest,
193
+ command: formatCommand(install),
194
+ } satisfies UpdateResult;
195
+ }
196
+
197
+ export async function fetchLatestVersion() {
198
+ const response = await fetch(registryUrl, {
199
+ headers: { accept: "application/json" },
200
+ signal: AbortSignal.timeout(10_000),
201
+ }).catch(
202
+ (cause) =>
203
+ new DiffmapUpdateError({
204
+ reason: "could not reach the npm registry",
205
+ cause,
206
+ }),
207
+ );
208
+ if (response instanceof Error) return response;
209
+ if (!response.ok) {
210
+ return new DiffmapUpdateError({
211
+ reason: `npm registry responded ${response.status} for ${packageName}`,
212
+ });
213
+ }
214
+ const body = (await response.json().catch(() => undefined)) as
215
+ | { version?: unknown }
216
+ | undefined;
217
+ if (typeof body?.version !== "string") {
218
+ return new DiffmapUpdateError({
219
+ reason: `npm registry returned no version for ${packageName}`,
220
+ });
221
+ }
222
+ return body.version;
223
+ }
224
+
225
+ function execCommand(command: Command) {
226
+ return new Promise<{ stdout: string; stderr: string } | Error>((resolve) => {
227
+ execFile(
228
+ command.command,
229
+ command.args,
230
+ { shell: process.platform === "win32", maxBuffer: 10_000_000 },
231
+ (cause, stdout, stderr) => {
232
+ if (cause) {
233
+ const detail = stderr.trim() || stdout.trim() || cause.message;
234
+ resolve(
235
+ new DiffmapUpdateError({
236
+ reason: `\`${formatCommand(command)}\` failed: ${detail}`,
237
+ cause,
238
+ }),
239
+ );
240
+ return;
241
+ }
242
+ resolve({ stdout, stderr });
243
+ },
244
+ );
245
+ });
246
+ }
247
+
248
+ async function readNpmGlobalRoot() {
249
+ const result = await execCommand({
250
+ command: "npm",
251
+ args: ["root", "--global"],
252
+ });
253
+ if (result instanceof Error) return undefined;
254
+ const root = result.stdout.trim();
255
+ if (root === "") return undefined;
256
+ try {
257
+ return fs.realpathSync(root);
258
+ } catch {
259
+ return root;
260
+ }
261
+ }
262
+
263
+ export async function detectInstallMethod() {
264
+ const entry = process.argv[1];
265
+ if (entry === undefined) return "unknown" satisfies InstallMethod;
266
+ let entryPath = entry;
267
+ try {
268
+ entryPath = fs.realpathSync(entry);
269
+ } catch {}
270
+ const withoutNpm = detectInstallMethodFromPath({
271
+ entryPath,
272
+ env: process.env,
273
+ });
274
+ if (withoutNpm !== "unknown") return withoutNpm;
275
+ return detectInstallMethodFromPath({
276
+ entryPath,
277
+ env: process.env,
278
+ npmGlobalRoot: await readNpmGlobalRoot(),
279
+ });
280
+ }
281
+
282
+ export async function runCommand(command: Command) {
283
+ const result = await execCommand(command);
284
+ if (result instanceof Error) return result;
285
+ }
@@ -50,7 +50,12 @@ export function CallStackDiff(
50
50
  );
51
51
  if (line.references.length === 0) {
52
52
  return (
53
- <div key={index} className={row} data-change={sign}>
53
+ <div
54
+ key={index}
55
+ className={row}
56
+ data-change={sign}
57
+ title={description}
58
+ >
54
59
  {content}
55
60
  </div>
56
61
  );
@@ -63,7 +68,7 @@ export function CallStackDiff(
63
68
  data-change={sign}
64
69
  aria-pressed={props.selectedAnnotation === line}
65
70
  aria-controls="source-diff-panel"
66
- title="Show source changes"
71
+ title={description}
67
72
  aria-label={description}
68
73
  onClick={() => props.onSelectAnnotation(line)}
69
74
  >
@@ -81,20 +86,24 @@ const styles = {
81
86
  spacing.padding({ y: 2 }),
82
87
  {
83
88
  backgroundColor: backgroundColor.app,
84
- overflowX: "auto",
89
+ overflowX: "hidden",
90
+ maxWidth: "100%",
85
91
  },
86
92
  ),
87
93
  row: style(
88
94
  spacing.padding({ x: 3 }),
89
95
  {
90
96
  display: "flex",
97
+ alignItems: "center",
91
98
  gap: "12px",
99
+ boxSizing: "border-box",
92
100
  width: "100%",
93
- minWidth: "max-content",
101
+ maxWidth: "100%",
102
+ minWidth: 0,
103
+ overflow: "hidden",
94
104
  textAlign: "left",
95
105
  font: "inherit",
96
106
  lineHeight: "1.6",
97
- whiteSpace: "pre",
98
107
  color: "inherit",
99
108
  background: "transparent",
100
109
  border: 0,
@@ -109,9 +118,19 @@ const styles = {
109
118
  "& .stack-content": {
110
119
  display: "flex",
111
120
  alignItems: "stretch",
121
+ flex: "1 1 auto",
122
+ minWidth: 0,
112
123
  minHeight: "28px",
124
+ overflow: "hidden",
125
+ },
126
+ "& .stack-label": {
127
+ flex: "1 1 auto",
128
+ minWidth: 0,
129
+ paddingBlock: "3px",
130
+ overflow: "hidden",
131
+ textOverflow: "ellipsis",
132
+ whiteSpace: "nowrap",
113
133
  },
114
- "& .stack-label": { paddingBlock: "3px" },
115
134
  "& .stack-comment": { color: colors.gray[11], fontWeight: 400 },
116
135
  "& .tree-prefix": { display: "inline-flex", alignSelf: "stretch" },
117
136
  "& [data-branch]": {
@@ -147,6 +166,7 @@ const styles = {
147
166
  "& > .diff-sign": { alignSelf: "center" },
148
167
  "& > .source-indicator": {
149
168
  alignSelf: "center",
169
+ flexShrink: 0,
150
170
  marginLeft: "auto",
151
171
  color: colors.blue[11],
152
172
  },
@@ -0,0 +1,16 @@
1
+ import { Button, Close, colors } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ const closeIcon = style({
5
+ color: colors.red[9],
6
+ });
7
+
8
+ export function CloseServerButton(props: { onClick?: () => void }) {
9
+ const iconClass = useStyles(closeIcon);
10
+ return (
11
+ <Button variant="default" onClick={props.onClick}>
12
+ <Close className={iconClass} size="sm" />
13
+ Close server
14
+ </Button>
15
+ );
16
+ }
@@ -4,6 +4,9 @@ import { useTheme } from "maui";
4
4
  import { useStyles } from "purse-styles";
5
5
  import { pierreFileOptions, pierreShell } from "./pierre.ts";
6
6
  import { useViewerMode } from "./viewerMode.ts";
7
+ import { useGitHubPin } from "../github/pin.ts";
8
+ import { useHostFiles } from "../host/files.ts";
9
+ import { fetchPinnedBlob } from "../github/fetchRepo.ts";
7
10
 
8
11
  type FileExcerptPayload = {
9
12
  path: string;
@@ -40,8 +43,36 @@ export function FileExcerpt(props: {
40
43
  function useFileExcerpt(input: { path: string; start: number; end: number }) {
41
44
  const [excerpt, setExcerpt] = useState<FileExcerptPayload>();
42
45
  const mode = useViewerMode();
46
+ const pin = useGitHubPin();
47
+ const hostFiles = useHostFiles();
48
+ const hostExcerpt = hostFileExcerpt(hostFiles, input);
43
49
  useEffect(() => {
44
- if (mode === "gist") return;
50
+ if (hostFiles !== undefined) return;
51
+ if (mode === "github") {
52
+ if (pin === undefined) return;
53
+ // oxlint-disable-next-line typescript/no-floating-promises -- React effects cannot await; this request owns the excerpt update.
54
+ void fetchPinnedBlob(pin, input.path)
55
+ .then((contents) => {
56
+ if (contents instanceof Error) return undefined;
57
+ const lines = contents.split(/\r?\n/);
58
+ const start = input.start;
59
+ const end = Math.min(input.end, lines.length);
60
+ return {
61
+ path: input.path,
62
+ start,
63
+ end,
64
+ contents: lines.slice(start - 1, end).join("\n"),
65
+ } satisfies FileExcerptPayload;
66
+ })
67
+ .then((value) => {
68
+ if (value !== undefined) setExcerpt(value);
69
+ })
70
+ .catch((cause) => {
71
+ console.warn("diffmap file excerpt failed", cause);
72
+ });
73
+ return;
74
+ }
75
+ if (mode !== "local") return;
45
76
  const params = new URLSearchParams({
46
77
  path: input.path,
47
78
  start: String(input.start),
@@ -60,6 +91,24 @@ function useFileExcerpt(input: { path: string; start: number; end: number }) {
60
91
  .catch((cause) => {
61
92
  console.warn("diffmap file excerpt failed", cause);
62
93
  });
63
- }, [input.path, input.start, input.end, mode]);
64
- return excerpt;
94
+ }, [input.path, input.start, input.end, mode, pin, hostFiles]);
95
+ return hostExcerpt ?? excerpt;
96
+ }
97
+
98
+ function hostFileExcerpt(
99
+ files: Record<string, string> | undefined,
100
+ input: { path: string; start: number; end: number },
101
+ ): FileExcerptPayload | undefined {
102
+ if (files === undefined) return undefined;
103
+ const contents = files[input.path];
104
+ if (contents === undefined) return undefined;
105
+ const lines = contents.split(/\r?\n/);
106
+ const start = input.start;
107
+ const end = Math.min(input.end, lines.length);
108
+ return {
109
+ path: input.path,
110
+ start,
111
+ end,
112
+ contents: lines.slice(start - 1, end).join("\n"),
113
+ };
65
114
  }
@@ -1,10 +1,15 @@
1
1
  import { colors, radius, spacing, text } from "maui";
2
2
  import { style, useStyles } from "purse-styles";
3
+ import { useViewerMode } from "./viewerMode.ts";
3
4
 
4
5
  export function HtmlPlaceholder(props: { inline?: boolean }) {
5
6
  const inline = props.inline === true;
6
7
  const className = useStyles(inline ? styles.inline : styles.block);
7
- const copy = "HTML from this gist is not rendered.";
8
+ const mode = useViewerMode();
9
+ const copy =
10
+ mode === "gist"
11
+ ? "HTML from this gist is not rendered."
12
+ : "HTML is not rendered on the hosted viewer.";
8
13
  if (inline) return <span className={className}>{copy}</span>;
9
14
  return (
10
15
  <aside className={className} data-diffmap-kind="html-disabled">