@moritzwilksch/diffle 0.1.7 → 0.1.9

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 (83) hide show
  1. package/README.md +91 -113
  2. package/dist/client/assets/{angular-html-gsHTuMGB.js → angular-html-CJY2n6PT.js} +1 -1
  3. package/dist/client/assets/{angular-ts-xhBJShFG.js → angular-ts-CZTM8_xo.js} +1 -1
  4. package/dist/client/assets/{apl-nsC6ra4J.js → apl-Dcp77-Tz.js} +1 -1
  5. package/dist/client/assets/{astro-X5_I_ruP.js → astro-C0gdsdoF.js} +1 -1
  6. package/dist/client/assets/{blade-BqlxrrkS.js → blade-BYpI0fWU.js} +1 -1
  7. package/dist/client/assets/{c-DVgCXnBp.js → c-DvFVe1b3.js} +1 -1
  8. package/dist/client/assets/{chapel-LgKo5FoM.js → chapel-qAWjGK-V.js} +1 -1
  9. package/dist/client/assets/{cobol-BVkDOaHP.js → cobol-BeFQKGES.js} +1 -1
  10. package/dist/client/assets/{coffee-BVuWHkH6.js → coffee-XLni1sjM.js} +1 -1
  11. package/dist/client/assets/{cpp-DnxElGuW.js → cpp-ChsJNYBC.js} +1 -1
  12. package/dist/client/assets/{crystal-CC8Vxuob.js → crystal-D3SbG8yD.js} +1 -1
  13. package/dist/client/assets/{css-BBuKGmvC.js → css-VXnGhPbY.js} +1 -1
  14. package/dist/client/assets/{edge-CNOZBEfw.js → edge-Dbzmpk4W.js} +1 -1
  15. package/dist/client/assets/{elixir-BG8NE-xg.js → elixir-DvQULd_0.js} +1 -1
  16. package/dist/client/assets/{elm-Cyp6TnT_.js → elm-BM5mpULp.js} +1 -1
  17. package/dist/client/assets/{erb-C58VxpfX.js → erb-nYCgf5TS.js} +1 -1
  18. package/dist/client/assets/{git-rebase-A6HkVJC_.js → git-rebase-CcDsi2L8.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-CqR_jN8G.js → glimmer-js-94igFmPU.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-M3ExOO4n.js → glimmer-ts-8aOBlhVe.js} +1 -1
  21. package/dist/client/assets/{glsl--WIxeXSU.js → glsl-DAW_sXZj.js} +1 -1
  22. package/dist/client/assets/{graphql-BXRMLGZY.js → graphql-BEeRZxhy.js} +1 -1
  23. package/dist/client/assets/{hack-C8N7KWCY.js → hack-e4pIPigB.js} +1 -1
  24. package/dist/client/assets/{haml-CxHPK-iA.js → haml-DvZH4eyc.js} +1 -1
  25. package/dist/client/assets/{handlebars-BNjxS1qh.js → handlebars-Bod10MIb.js} +1 -1
  26. package/dist/client/assets/{html-CO9JbMOk.js → html-BOKVgXUz.js} +1 -1
  27. package/dist/client/assets/{html-derivative-CD1w-J8T.js → html-derivative-B5DftdEs.js} +1 -1
  28. package/dist/client/assets/{http-DvdIZe4B.js → http-Bli9WHgJ.js} +1 -1
  29. package/dist/client/assets/{hurl-BPchDEWD.js → hurl-C45hn_gr.js} +1 -1
  30. package/dist/client/assets/index-BugDKSp3.js +2994 -0
  31. package/dist/client/assets/index-CvgG7WNQ.css +2 -0
  32. package/dist/client/assets/{java-Cw12dAwx.js → java-BxezylI4.js} +1 -1
  33. package/dist/client/assets/{javascript-CVrIgFqG.js → javascript-CGUJoh42.js} +1 -1
  34. package/dist/client/assets/{jinja-Dcw1Cb-z.js → jinja-Dhrq1TMH.js} +1 -1
  35. package/dist/client/assets/{jison-BCHOo1FF.js → jison-CK02w6aE.js} +1 -1
  36. package/dist/client/assets/{json-BkfEJ7uD.js → json-DbjMq68q.js} +1 -1
  37. package/dist/client/assets/{jsx-CBE97uyR.js → jsx-BVYjBFnX.js} +1 -1
  38. package/dist/client/assets/{julia-Dt37WVZB.js → julia-DUG0dPfw.js} +1 -1
  39. package/dist/client/assets/{just-DFHYF8cW.js → just-BzILq64e.js} +1 -1
  40. package/dist/client/assets/{latex-BtlIyAq9.js → latex-DJ-r-_gD.js} +1 -1
  41. package/dist/client/assets/{liquid-PdbqGeMi.js → liquid-CUeuBds8.js} +1 -1
  42. package/dist/client/assets/{lua-lhtz56hU.js → lua-CUx56wFu.js} +1 -1
  43. package/dist/client/assets/{marko-DPsaza10.js → marko-zidFMx4y.js} +1 -1
  44. package/dist/client/assets/{mdc-DIH2Ec5y.js → mdc-B8YDeE-G.js} +1 -1
  45. package/dist/client/assets/{nginx-bHk_XM7O.js → nginx-CLmnmibq.js} +1 -1
  46. package/dist/client/assets/{nim-BgUabkBA.js → nim-D_ow61_F.js} +1 -1
  47. package/dist/client/assets/{org-PMkj4aaP.js → org-D6yFID6y.js} +1 -1
  48. package/dist/client/assets/{perl-B1ZCbepN.js → perl-DlRNqEum.js} +1 -1
  49. package/dist/client/assets/{php-Vj6Jmzuy.js → php-CPe77eH-.js} +1 -1
  50. package/dist/client/assets/{pug-B0ziVZ95.js → pug-CzDq8hOp.js} +1 -1
  51. package/dist/client/assets/{qml--NUJOMX2.js → qml-CmVqwUZS.js} +1 -1
  52. package/dist/client/assets/{r-CCy6kg5Q.js → r-CZql-PDg.js} +1 -1
  53. package/dist/client/assets/{razor-DBTJol_D.js → razor-Dp5yql5G.js} +1 -1
  54. package/dist/client/assets/{regexp-B-0M8p3f.js → regexp-DjyDhvtX.js} +1 -1
  55. package/dist/client/assets/{rst-DVPZR2Zw.js → rst-B-Q6Ljb6.js} +1 -1
  56. package/dist/client/assets/{ruby-CY-1xRWL.js → ruby-BQD6FS2h.js} +1 -1
  57. package/dist/client/assets/{sas-BfWvhRC7.js → sas-DDGcym6k.js} +1 -1
  58. package/dist/client/assets/{scss-CDSNB-cI.js → scss-DNPPx0rx.js} +1 -1
  59. package/dist/client/assets/{shellscript-x3a-v0TL.js → shellscript-CHWpWo83.js} +1 -1
  60. package/dist/client/assets/{shellsession-CBXmn1pk.js → shellsession-_KE7LUMO.js} +1 -1
  61. package/dist/client/assets/{soy-Dx9wgv4j.js → soy-B57xVh7W.js} +1 -1
  62. package/dist/client/assets/{sql-GJE8dpQY.js → sql-8OXlDHCR.js} +1 -1
  63. package/dist/client/assets/{stata-Cu3eQvZM.js → stata-C452rT5l.js} +1 -1
  64. package/dist/client/assets/{surrealql-BSIyNQF3.js → surrealql-B5gNeOUk.js} +1 -1
  65. package/dist/client/assets/{svelte-BC05E7SM.js → svelte-Cv_BwjgR.js} +1 -1
  66. package/dist/client/assets/{templ-BZb4iLGg.js → templ-qZdOs42N.js} +1 -1
  67. package/dist/client/assets/{tex-BgSQdktB.js → tex-BK-MJpDM.js} +1 -1
  68. package/dist/client/assets/{ts-tags-ByZvgo6s.js → ts-tags-C5Q-Olrs.js} +1 -1
  69. package/dist/client/assets/{tsx-BkywoqnG.js → tsx-DwPm0lid.js} +1 -1
  70. package/dist/client/assets/{twig-DT3o-XyD.js → twig-DKj0IG75.js} +1 -1
  71. package/dist/client/assets/{typescript-Dc_VYLnG.js → typescript-BeWKsdVP.js} +1 -1
  72. package/dist/client/assets/{typst-Dufd3udg.js → typst-B7u-3Y0n.js} +1 -1
  73. package/dist/client/assets/{vue-CWow0PK2.js → vue-BkC1F40W.js} +1 -1
  74. package/dist/client/assets/{vue-html-Dukrnida.js → vue-html-BeYXHyr2.js} +1 -1
  75. package/dist/client/assets/{vue-vine-DeFkcU4G.js → vue-vine-ymsHpgRC.js} +1 -1
  76. package/dist/client/assets/{xml-w1QbHsNc.js → xml-B6M3xnfe.js} +1 -1
  77. package/dist/client/assets/{xsl-DQ1uA8pZ.js → xsl-CSlbm6tK.js} +1 -1
  78. package/dist/client/assets/{yaml-Bt6SDR__.js → yaml-fMwD58zK.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/main.js +426 -85
  81. package/package.json +19 -3
  82. package/dist/client/assets/index-B47iHZXu.css +0 -2
  83. package/dist/client/assets/index-BcTYvSA1.js +0 -2989
@@ -11,6 +11,7 @@ import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readlink, realpath, re
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { homedir, hostname, tmpdir } from "node:os";
13
13
  import { createServer } from "node:http";
14
+ import { getAssetKeys, getRawAsset, isSea } from "node:sea";
14
15
  import { Http2ServerRequest, constants as constants$1 } from "http2";
15
16
  import { Readable } from "stream";
16
17
  import crypto from "crypto";
@@ -3951,7 +3952,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3951
3952
  }
3952
3953
  //#endregion
3953
3954
  //#region package.json
3954
- var version$1 = "0.1.7";
3955
+ var version$1 = "0.1.9";
3955
3956
  //#endregion
3956
3957
  //#region src/server/comments/anchor.ts
3957
3958
  function splitLines(contents) {
@@ -4198,6 +4199,7 @@ var GitRepo = class GitRepo {
4198
4199
  new: next
4199
4200
  };
4200
4201
  }
4202
+ /** A commit's hash, abbreviation and full message; null when `rev` names no commit. */
4201
4203
  async commitInfo(rev) {
4202
4204
  let sha;
4203
4205
  try {
@@ -4221,6 +4223,66 @@ var GitRepo = class GitRepo {
4221
4223
  message: output.slice(separator + 1).trimEnd()
4222
4224
  };
4223
4225
  }
4226
+ /**
4227
+ * `sha`'s first parent, and the commit toward HEAD whose first parent it is. The
4228
+ * child is null at HEAD, on an unborn branch, or when `sha` is off HEAD's
4229
+ * first-parent line, e.g. on a merged side branch.
4230
+ */
4231
+ async commitNeighbours(sha) {
4232
+ const [parent, list] = await Promise.all([this.resolve(`${sha}^`).catch((e) => {
4233
+ if (e instanceof GitError && e.code === 1) return null;
4234
+ throw e;
4235
+ }), this.text([
4236
+ "rev-list",
4237
+ "--first-parent",
4238
+ "--ancestry-path",
4239
+ "--parents",
4240
+ `${sha}..HEAD`,
4241
+ "--"
4242
+ ]).catch(() => "")]);
4243
+ const [child, firstParent] = list.trim().split("\n").pop()?.split(" ") ?? [];
4244
+ return {
4245
+ parent,
4246
+ child: child && firstParent === sha ? child : null
4247
+ };
4248
+ }
4249
+ /** The commits a revision range such as `a..b` or `c^!` selects, oldest first: the newest `limit`, and the total. */
4250
+ async rangeCommits(range, limit) {
4251
+ const [count, log] = await Promise.all([this.text([
4252
+ "rev-list",
4253
+ "--count",
4254
+ "--end-of-options",
4255
+ range,
4256
+ "--"
4257
+ ]), this.text([
4258
+ "log",
4259
+ "-z",
4260
+ "--no-show-signature",
4261
+ "--reverse",
4262
+ `--max-count=${limit}`,
4263
+ "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%B",
4264
+ "--end-of-options",
4265
+ range,
4266
+ "--"
4267
+ ])]);
4268
+ const fields = log.split("\0");
4269
+ const list = [];
4270
+ for (let i = 0; i + 5 < fields.length; i += 6) {
4271
+ const [sha = "", short = "", author = "", email = "", time = "", message = ""] = fields.slice(i, i + 6);
4272
+ list.push({
4273
+ sha,
4274
+ short,
4275
+ message: message.trimEnd(),
4276
+ author,
4277
+ email,
4278
+ date: Number(time) * 1e3
4279
+ });
4280
+ }
4281
+ return {
4282
+ list,
4283
+ total: Number(count.trim())
4284
+ };
4285
+ }
4224
4286
  /** The empty tree under the repository's hash algorithm: the old side of an unborn branch. */
4225
4287
  async emptyTree() {
4226
4288
  return (await this.text([
@@ -4369,7 +4431,7 @@ var GitRepo = class GitRepo {
4369
4431
  }
4370
4432
  /**
4371
4433
  * Changed files between two revs, or a rev and the worktree (incl. untracked).
4372
- * One diff call yields status, line counts and, against a commit, the new-side
4434
+ * One diff call yields status, line counts and, against a commit, each side's
4373
4435
  * blob; the worktree's blobs are hashed from disk.
4374
4436
  */
4375
4437
  async numstat(oldRev, newRev) {
@@ -4395,6 +4457,7 @@ var GitRepo = class GitRepo {
4395
4457
  deletions: 0,
4396
4458
  binary: false,
4397
4459
  blob: "",
4460
+ oldBlob: "",
4398
4461
  generated: false
4399
4462
  };
4400
4463
  const st = await lstat(resolve(this.root, path)).catch(() => null);
@@ -4411,44 +4474,68 @@ var GitRepo = class GitRepo {
4411
4474
  return file;
4412
4475
  });
4413
4476
  const all = [...files, ...extra].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
4414
- if (newRev === "worktree") await this.hashWorktree(all.filter((f) => f.status !== "D"));
4477
+ if (newRev === "worktree") {
4478
+ const present = all.filter((f) => f.status !== "D");
4479
+ (await this.hashWorktree(present)).forEach((sha, i) => present[i].blob = sha);
4480
+ }
4481
+ if (reverse) {
4482
+ const present = all.filter((f) => f.status !== "A");
4483
+ const entries = present.map((f) => ({
4484
+ path: f.oldPath ?? f.path,
4485
+ submodule: f.submodule
4486
+ }));
4487
+ (await this.hashWorktree(entries)).forEach((sha, i) => present[i].oldBlob = sha);
4488
+ }
4415
4489
  return all;
4416
4490
  }
4417
4491
  /**
4418
- * Sets `blob` to what git would store for each worktree entry: the file's
4492
+ * What git would store for each worktree entry, in order: the file's
4419
4493
  * blob, a symlink's target blob, a submodule's checked-out commit. Regular
4420
4494
  * files go to one `hash-object --stdin-paths` call; everything else is hashed
4421
4495
  * on its own, so a gitlink, a broken link or an unreadable path never fails
4422
4496
  * the batch. A path containing a newline cannot ride the stdin list either.
4423
- * A path that cannot be hashed keeps an empty blob.
4497
+ * A path that cannot be hashed gets an empty blob.
4424
4498
  */
4425
4499
  async hashWorktree(files) {
4500
+ const shas = files.map(() => "");
4426
4501
  const regular = [];
4427
- await mapLimit(files, READ_CONCURRENCY$1, async (f) => {
4428
- f.blob = "";
4429
- if (f.submodule) {
4430
- f.blob = await this.submoduleHead(f.path);
4502
+ await mapLimit([...files.keys()], READ_CONCURRENCY$1, async (i) => {
4503
+ const { path, submodule } = files[i];
4504
+ if (submodule) {
4505
+ shas[i] = await this.submoduleHead(path);
4431
4506
  return;
4432
4507
  }
4433
- const st = await lstat(resolve(this.root, f.path)).catch(() => null);
4434
- if (st?.isSymbolicLink()) f.blob = blobSha(Buffer.from(await readlink(resolve(this.root, f.path)), "utf8"));
4435
- else if (st?.isFile()) regular.push(f);
4508
+ const st = await lstat(resolve(this.root, path)).catch(() => null);
4509
+ if (st?.isSymbolicLink()) shas[i] = blobSha(Buffer.from(await readlink(resolve(this.root, path)), "utf8"));
4510
+ else if (st?.isFile()) regular.push(i);
4436
4511
  });
4437
- const batch = regular.filter((f) => !f.path.includes("\n"));
4438
- const single = regular.filter((f) => f.path.includes("\n"));
4512
+ const batch = regular.filter((i) => !files[i].path.includes("\n"));
4513
+ const single = regular.filter((i) => files[i].path.includes("\n"));
4439
4514
  if (batch.length) try {
4440
- const shas = (await this.text(["hash-object", "--stdin-paths"], { input: batch.map((f) => f.path).join("\n") + "\n" })).trim().split("\n");
4441
- batch.forEach((f, i) => f.blob = shas[i] ?? "");
4515
+ const lines = (await this.text(["hash-object", "--stdin-paths"], { input: batch.map((i) => files[i].path).join("\n") + "\n" })).trim().split("\n");
4516
+ batch.forEach((i, j) => shas[i] = lines[j] ?? "");
4442
4517
  } catch {
4443
4518
  single.push(...batch);
4444
4519
  }
4445
- await mapLimit(single, READ_CONCURRENCY$1, async (f) => {
4446
- f.blob = await this.text([
4520
+ await mapLimit(single, READ_CONCURRENCY$1, async (i) => {
4521
+ shas[i] = await this.text([
4447
4522
  "hash-object",
4448
4523
  "--",
4449
- f.path
4524
+ files[i].path
4450
4525
  ]).then((s) => s.trim()).catch(() => "");
4451
4526
  });
4527
+ return shas;
4528
+ }
4529
+ /**
4530
+ * What git would store for `content` at worktree `path`, its clean filters applied: equals the
4531
+ * snapshot's blob for a worktree file whose bytes are still the snapshot's.
4532
+ */
4533
+ async hashObject(content, path) {
4534
+ return (await this.text([
4535
+ "hash-object",
4536
+ "--stdin",
4537
+ `--path=${path}`
4538
+ ], { input: content })).trim();
4452
4539
  }
4453
4540
  /** The commit a checked-out submodule is at, or '' if it is not a repository. */
4454
4541
  async submoduleHead(path) {
@@ -5048,8 +5135,8 @@ function splitZ(buf) {
5048
5135
  * `-z -M --raw --no-abbrev --numstat`: the raw records come first, then the numstat records,
5049
5136
  * each path-terminated by NUL. Raw: ":mode mode sha sha X\0path\0", renames and
5050
5137
  * copies "R100\0old\0new\0". Numstat: "add\tdel\tpath\0", renames "add\tdel\t\0old\0new\0".
5051
- * Binary files count as "-\t-". The dst sha is the new-side blob against a
5052
- * commit; against the worktree it is zeros and the caller hashes the file.
5138
+ * Binary files count as "-\t-". The src and dst shas are each side's blob
5139
+ * against a commit; against the worktree, or where a side is absent, they are zeros.
5053
5140
  * Mode 160000 on either side marks a gitlink, which no file operation may touch.
5054
5141
  */
5055
5142
  function parseRawNumstat(buf) {
@@ -5057,7 +5144,7 @@ function parseRawNumstat(buf) {
5057
5144
  const byPath = /* @__PURE__ */ new Map();
5058
5145
  let i = 0;
5059
5146
  for (; i < parts.length && parts[i].startsWith(":"); i++) {
5060
- const [srcMode = "", dstMode = "", , dstSha = "", code = ""] = parts[i].split(" ");
5147
+ const [srcMode = "", dstMode = "", srcSha = "", dstSha = "", code = ""] = parts[i].split(" ");
5061
5148
  const status = code[0];
5062
5149
  const oldPath = status === "R" || status === "C" ? parts[++i] : void 0;
5063
5150
  const path = parts[++i];
@@ -5068,6 +5155,7 @@ function parseRawNumstat(buf) {
5068
5155
  deletions: 0,
5069
5156
  binary: false,
5070
5157
  blob: /^0+$/.test(dstSha) ? "" : dstSha,
5158
+ oldBlob: /^0+$/.test(srcSha) ? "" : srcSha,
5071
5159
  generated: false
5072
5160
  };
5073
5161
  if (oldPath != null) file.oldPath = oldPath;
@@ -12073,14 +12161,26 @@ const ModeRequestSchema = discriminatedUnion("kind", [
12073
12161
  object({
12074
12162
  kind: literal("revspec"),
12075
12163
  args: string().array()
12164
+ }),
12165
+ object({
12166
+ kind: literal("focus"),
12167
+ commit: string().nullable()
12076
12168
  })
12077
12169
  ]);
12078
- /** A comparison; both endpoints accept a Git revision or "worktree". */
12079
- const ModeSpecSchema = object({
12170
+ /**
12171
+ * How the old side is derived: `old` itself, merge-base(old, new) with worktree
12172
+ * taken as HEAD, or new's first parent (the empty tree for a root commit).
12173
+ */
12174
+ const ComparisonBaseSchema = _enum([
12175
+ "direct",
12176
+ "merge-base",
12177
+ "parent"
12178
+ ]);
12179
+ /** A comparison; both endpoints accept a Git revision or "worktree". Under `parent`, both name the one commit. */
12180
+ const ComparisonSchema = object({
12080
12181
  old: string(),
12081
12182
  new: string(),
12082
- /** Compare merge-base(old, new) to new; worktree uses HEAD for the merge base. */
12083
- mergeBase: boolean(),
12183
+ base: ComparisonBaseSchema,
12084
12184
  live: _enum([
12085
12185
  "worktree",
12086
12186
  "refs",
@@ -12089,10 +12189,18 @@ const ModeSpecSchema = object({
12089
12189
  /** Fixed when the comparison is entered; discovery never changes comment storage. */
12090
12190
  commentKey: string()
12091
12191
  });
12092
- /** Display comparison endpoints with branch names instead of internal ref namespaces. */
12192
+ const ModeSpecSchema = ComparisonSchema.extend({
12193
+ /** Set while one commit of a range is focused: the range, whose commits the snapshot keeps listing. */
12194
+ within: ComparisonSchema.optional() });
12195
+ /**
12196
+ * Display comparison endpoints with branch names instead of internal ref namespaces; a single commit as
12197
+ * `<sha>^!`, or as `<range> @ <sha>` when focused within a range.
12198
+ */
12093
12199
  function comparisonLabel(mode) {
12200
+ if (mode.within) return `${comparisonLabel(mode.within)} @ ${mode.new.slice(0, 7)}`;
12201
+ if (mode.base === "parent") return `${mode.new.slice(0, 7)}^!`;
12094
12202
  const name = (rev) => rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
12095
- return `${name(mode.old)}${mode.mergeBase ? "..." : ".."}${name(mode.new)}`;
12203
+ return `${name(mode.old)}${mode.base === "merge-base" ? "..." : ".."}${name(mode.new)}`;
12096
12204
  }
12097
12205
  const GithubPullRequestSchema = object({
12098
12206
  repository: string(),
@@ -12135,11 +12243,39 @@ const ChangedFileSchema = object({
12135
12243
  binary: boolean(),
12136
12244
  /** New-side blob sha. '' for deletions. Keys the viewed state. */
12137
12245
  blob: string(),
12246
+ /** Old-side blob sha. '' for additions. A worktree side's blob, like `blob`, is '' when the file cannot be hashed. */
12247
+ oldBlob: string(),
12138
12248
  /** The `linguist-generated` gitattribute, a path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
12139
12249
  generated: boolean(),
12140
12250
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
12141
12251
  submodule: literal(true).optional()
12142
12252
  });
12253
+ const CommitInfoSchema = object({
12254
+ sha: string(),
12255
+ short: string(),
12256
+ message: string()
12257
+ });
12258
+ /** A single reviewed commit and its neighbours along HEAD's first-parent history, for stepping through it. */
12259
+ const ReviewedCommitSchema = CommitInfoSchema.extend({
12260
+ /** First parent; null for a root commit. */
12261
+ parent: string().nullable(),
12262
+ /** The commit whose first parent this is, toward HEAD; null at HEAD or off HEAD's first-parent line. */
12263
+ child: string().nullable()
12264
+ });
12265
+ /**
12266
+ * The commits a comparison spans: old..new, with the worktree taken as HEAD, or the one commit under `parent`.
12267
+ * A focused commit lists its range's.
12268
+ */
12269
+ const RangeCommitsSchema = object({
12270
+ /** Oldest first; the newest MAX_RANGE_COMMITS when there are more. */
12271
+ list: CommitInfoSchema.extend({
12272
+ author: string(),
12273
+ email: string(),
12274
+ /** Author date, epoch milliseconds. */
12275
+ date: number()
12276
+ }).array(),
12277
+ total: number()
12278
+ });
12143
12279
  object({
12144
12280
  root: string(),
12145
12281
  mode: ModeSpecSchema,
@@ -12154,7 +12290,10 @@ object({
12154
12290
  context: number(),
12155
12291
  changed: ChangedFileSchema.array(),
12156
12292
  /** All paths on the new side: the new commit's tree, or index ∪ untracked for the worktree. Sorted. */
12157
- tree: string().array()
12293
+ tree: string().array(),
12294
+ /** The reviewed commit when the comparison is a single commit against its parent; otherwise null. */
12295
+ commit: ReviewedCommitSchema.nullable(),
12296
+ commits: RangeCommitsSchema
12158
12297
  });
12159
12298
  /** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
12160
12299
  const PatchRequestSchema = object({ paths: string().array().max(200) });
@@ -12251,11 +12390,6 @@ const ViewedEntrySchema = object({
12251
12390
  blob: string(),
12252
12391
  viewed: boolean()
12253
12392
  });
12254
- const CommitInfoSchema = object({
12255
- sha: string(),
12256
- short: string(),
12257
- message: string()
12258
- });
12259
12393
  object({
12260
12394
  old: CommitInfoSchema.nullable(),
12261
12395
  new: CommitInfoSchema.nullable()
@@ -12498,6 +12632,21 @@ const FileQuerySchema = object({
12498
12632
  path: string().min(1),
12499
12633
  rev: SideSchema
12500
12634
  });
12635
+ /**
12636
+ * `GET /api/image`: one side of a file as image bytes. The server answers 404 unless `key` is that side's
12637
+ * `imageKey` in its current snapshot, the bytes are still the ones the key names, and they are a raster image.
12638
+ */
12639
+ const ImageQuerySchema = FileQuerySchema.extend({ key: string().min(1) });
12640
+ /**
12641
+ * Names the bytes of one side of `path`, or null when the side has no name: a changed file's blob on that
12642
+ * side, else the commit of a side that is one, since an unchanged path is alike on both. What a key names
12643
+ * never changes, so an image served under it may be cached for good.
12644
+ */
12645
+ function imageKey(snap, path, side) {
12646
+ const file = snap.changed.find((f) => f.path === path);
12647
+ if (file) return (side === "new" ? file.blob : file.oldBlob) || null;
12648
+ return [snap.newSha, snap.oldSha].find((rev) => rev !== "worktree") ?? null;
12649
+ }
12501
12650
  const offsetSchema = string().regex(/^[0-9]+$/).transform(Number).pipe(number().int().nonnegative());
12502
12651
  const LastCommitsQuerySchema = object({
12503
12652
  oldOffset: offsetSchema,
@@ -14001,6 +14150,7 @@ const WORKTREE = "worktree";
14001
14150
  * "a..b" → a vs b
14002
14151
  * "a...b" → merge-base(a, b) vs b
14003
14152
  * "a" "b" → a vs b
14153
+ * "a^!" → a vs its first parent: the one commit, as git show shows it
14004
14154
  * Empty sides default to HEAD. Either side accepts "worktree" for the uncommitted
14005
14155
  * tree; a merge base against it is taken against HEAD, the commit it sits on.
14006
14156
  */
@@ -14009,34 +14159,45 @@ function parseRevspec(args) {
14009
14159
  if (args.length === 2) {
14010
14160
  const [a, b] = args;
14011
14161
  if (a.includes("..") || b.includes("..")) throw new RevspecError("cannot combine \"..\" with two revisions");
14162
+ if (a.endsWith("^!") || b.endsWith("^!")) throw new RevspecError("cannot combine \"^!\" with two revisions");
14012
14163
  return {
14013
14164
  old: a,
14014
14165
  new: b,
14015
- mergeBase: false
14166
+ base: "direct"
14016
14167
  };
14017
14168
  }
14018
14169
  const arg = args[0];
14170
+ if (arg.endsWith("^!")) {
14171
+ const commit = arg.slice(0, -2) || "HEAD";
14172
+ if (commit.includes("..")) throw new RevspecError("cannot combine \"^!\" with \"..\"");
14173
+ if (commit === WORKTREE) throw new RevspecError("the worktree is not a commit");
14174
+ return {
14175
+ old: commit,
14176
+ new: commit,
14177
+ base: "parent"
14178
+ };
14179
+ }
14019
14180
  const three = arg.indexOf("...");
14020
14181
  if (three !== -1) return {
14021
14182
  old: arg.slice(0, three) || "HEAD",
14022
14183
  new: arg.slice(three + 3) || "HEAD",
14023
- mergeBase: true
14184
+ base: "merge-base"
14024
14185
  };
14025
14186
  const two = arg.indexOf("..");
14026
14187
  if (two !== -1) return {
14027
14188
  old: arg.slice(0, two) || "HEAD",
14028
14189
  new: arg.slice(two + 2) || "HEAD",
14029
- mergeBase: false
14190
+ base: "direct"
14030
14191
  };
14031
14192
  if (arg === WORKTREE) return {
14032
14193
  old: "HEAD",
14033
14194
  new: WORKTREE,
14034
- mergeBase: false
14195
+ base: "direct"
14035
14196
  };
14036
14197
  return {
14037
14198
  old: arg,
14038
14199
  new: "HEAD",
14039
- mergeBase: true
14200
+ base: "merge-base"
14040
14201
  };
14041
14202
  }
14042
14203
  var RevspecError = class extends Error {};
@@ -16766,7 +16927,7 @@ const LOOPBACK = /* @__PURE__ */ new Set([
16766
16927
  "::1",
16767
16928
  "localhost"
16768
16929
  ]);
16769
- const HINT = "if a reverse proxy sent it, trust that origin with --allowed-origin";
16930
+ const UNCONFIGURED = "if a reverse proxy sent it, trust that origin with --allowed-origin";
16770
16931
  /**
16771
16932
  * Restricts API and WebSocket requests to local hosts and the configured public origin.
16772
16933
  * Proxies may preserve the public Host or rewrite it to an allowed upstream Host.
@@ -16780,15 +16941,16 @@ function requestGuard(bindHost, names = ownNames(), allowedOrigin) {
16780
16941
  ...names.map((n) => n.toLowerCase())
16781
16942
  ]);
16782
16943
  const publicHost = allowedOrigin == null ? void 0 : new URL(allowedOrigin).host;
16944
+ const hint = allowedOrigin == null ? UNCONFIGURED : `allowed origin is "${allowedOrigin}"`;
16783
16945
  return ({ host, origin }) => {
16784
16946
  if (!host) return "missing Host header";
16785
16947
  const name = hostnameOf(host).toLowerCase();
16786
16948
  const localHost = loopback ? LOOPBACK.has(name) : isIP(name) !== 0 || allowed.has(name);
16787
16949
  const proxyHost = publicHost != null && host.toLowerCase() === publicHost;
16788
- if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${HINT}`;
16950
+ if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${hint}`;
16789
16951
  if (origin == null || allowedOrigin != null && origin === allowedOrigin) return null;
16790
16952
  if (!proxyHost && parseHost(origin) === host) return null;
16791
- return `forbidden Origin "${origin}" for Host "${host}"; ${HINT}`;
16953
+ return `forbidden Origin "${origin}" for Host "${host}"; ${hint}`;
16792
16954
  };
16793
16955
  }
16794
16956
  /** The host of a syntactically valid origin; undefined for `null` and other non-URLs. */
@@ -17285,6 +17447,67 @@ function migrateV1(v1) {
17285
17447
  };
17286
17448
  }
17287
17449
  //#endregion
17450
+ //#region src/server/image.ts
17451
+ /** Leading bytes of the raster formats browsers display; `null` matches any byte. */
17452
+ const SIGNATURES = [
17453
+ ["image/png", [
17454
+ 137,
17455
+ 80,
17456
+ 78,
17457
+ 71,
17458
+ 13,
17459
+ 10,
17460
+ 26,
17461
+ 10
17462
+ ]],
17463
+ ["image/jpeg", [
17464
+ 255,
17465
+ 216,
17466
+ 255
17467
+ ]],
17468
+ ["image/gif", ascii("GIF8")],
17469
+ ["image/webp", [
17470
+ ...ascii("RIFF"),
17471
+ null,
17472
+ null,
17473
+ null,
17474
+ null,
17475
+ ...ascii("WEBP")
17476
+ ]],
17477
+ ["image/avif", [
17478
+ null,
17479
+ null,
17480
+ null,
17481
+ null,
17482
+ ...ascii("ftypavif")
17483
+ ]],
17484
+ ["image/avif", [
17485
+ null,
17486
+ null,
17487
+ null,
17488
+ null,
17489
+ ...ascii("ftypavis")
17490
+ ]],
17491
+ ["image/bmp", ascii("BM")],
17492
+ ["image/x-icon", [
17493
+ 0,
17494
+ 0,
17495
+ 1,
17496
+ 0
17497
+ ]]
17498
+ ];
17499
+ function ascii(s) {
17500
+ return [...s].map((ch) => ch.charCodeAt(0));
17501
+ }
17502
+ /**
17503
+ * The media type of a raster image, read from its leading bytes; null for anything else. The
17504
+ * extension is not consulted: only bytes that are an image get served as one.
17505
+ */
17506
+ function imageType(buf) {
17507
+ for (const [type, magic] of SIGNATURES) if (buf.length >= magic.length && magic.every((b, i) => b == null || buf[i] === b)) return type;
17508
+ return null;
17509
+ }
17510
+ //#endregion
17288
17511
  //#region src/server/routes.ts
17289
17512
  /** Invalid JSON is a request error; failures after parsing still propagate. */
17290
17513
  async function readJson(c) {
@@ -17350,6 +17573,18 @@ function createApi(deps) {
17350
17573
  };
17351
17574
  return c.json(body);
17352
17575
  });
17576
+ app.get("/api/image", async (c) => {
17577
+ const { path, rev, key } = ImageQuerySchema.parse(c.req.query());
17578
+ const buf = await session.readImage(await session.snapshotter.current(), path, rev, key);
17579
+ const type = buf && imageType(buf);
17580
+ if (!type) return c.json({ error: "no image under that key" }, 404);
17581
+ return c.body(new Uint8Array(buf), 200, {
17582
+ "content-type": type,
17583
+ "cache-control": "private, max-age=31536000, immutable",
17584
+ "x-content-type-options": "nosniff",
17585
+ "cross-origin-resource-policy": "same-origin"
17586
+ });
17587
+ });
17353
17588
  app.get("/api/search", async (c) => {
17354
17589
  const query = SearchQuerySchema.parse(c.req.query());
17355
17590
  const { q, scope } = query;
@@ -17588,7 +17823,8 @@ var Server = class {
17588
17823
  app.use("/api/*", compress());
17589
17824
  app.route("/", createApi(this.deps));
17590
17825
  app.all("/api/*", (c) => c.json({ error: "API endpoint not found. Restart diffle to load the latest server." }, 404));
17591
- if (!this.opts.dev) {
17826
+ if (!this.opts.dev && isSea()) app.get("*", serveEmbedded);
17827
+ else if (!this.opts.dev) {
17592
17828
  const clientDir = resolveClientDir();
17593
17829
  app.use("*", serveStatic({ root: clientDir }));
17594
17830
  app.get("*", serveStatic({
@@ -17675,7 +17911,14 @@ function resolveClientDir() {
17675
17911
  return join(projectRoot(), "dist", "client");
17676
17912
  }
17677
17913
  function hasClientBuild() {
17678
- return existsSync(join(resolveClientDir(), "index.html"));
17914
+ return isSea() || existsSync(join(resolveClientDir(), "index.html"));
17915
+ }
17916
+ const embedded = isSea() ? new Set(getAssetKeys()) : /* @__PURE__ */ new Set();
17917
+ function serveEmbedded(c) {
17918
+ const path = c.req.path.slice(1);
17919
+ const key = embedded.has(path) ? path : "index.html";
17920
+ const type = getMimeType(key) ?? "application/octet-stream";
17921
+ return c.body(new Uint8Array(getRawAsset(key)), 200, { "content-type": type });
17679
17922
  }
17680
17923
  //#endregion
17681
17924
  //#region src/server/github/pulls.ts
@@ -18190,17 +18433,19 @@ function describe(skipped) {
18190
18433
  * A--B--C main
18191
18434
  * \--D--E HEAD
18192
18435
  *
18193
- * old new mergeBase oldSha newSha live
18194
- * main HEAD false C E refs
18195
- * main HEAD true B E refs
18196
- * HEAD worktree false E worktree worktree
18197
- * worktree HEAD false worktree E worktree
18198
- * main worktree true B worktree worktree
18199
- * <C hash> <E hash> false C E none
18436
+ * old new base oldSha newSha live
18437
+ * main HEAD direct C E refs
18438
+ * main HEAD merge-base B E refs
18439
+ * HEAD worktree direct E worktree worktree
18440
+ * worktree HEAD direct worktree E worktree
18441
+ * main worktree merge-base B worktree worktree
18442
+ * <C hash> <E hash> direct C E none
18443
+ * <E hash> <E hash> parent D E none
18200
18444
  *
18201
- * mergeBase replaces only the old endpoint with the common ancestor, using
18202
- * HEAD for any worktree input. live is "worktree" if either input is worktree,
18203
- * "none" if both inputs are pinned hashes, and "refs" otherwise.
18445
+ * merge-base replaces only the old endpoint with the common ancestor, using
18446
+ * HEAD for any worktree input; parent replaces it with new's first parent, or
18447
+ * the empty tree for a root commit. live is "worktree" if either input is
18448
+ * worktree, "none" if both inputs are pinned hashes, and "refs" otherwise.
18204
18449
  *
18205
18450
  * In an unborn repository, HEAD compared directly with worktree resolves to
18206
18451
  * the empty tree. Invalid revisions or missing merge bases throw RevspecError.
@@ -18212,14 +18457,14 @@ async function resolveComparison(repo, comparison) {
18212
18457
  try {
18213
18458
  return await repo.resolve(rev);
18214
18459
  } catch (e) {
18215
- if (!comparison.mergeBase && rev === "HEAD" && endpoints.includes("worktree") && e instanceof GitError && e.code === 1) return repo.emptyTree();
18460
+ if (comparison.base === "direct" && rev === "HEAD" && endpoints.includes("worktree") && e instanceof GitError && e.code === 1) return repo.emptyTree();
18216
18461
  if (e instanceof GitError) throw new RevspecError(`unknown revision: ${rev}`);
18217
18462
  throw e;
18218
18463
  }
18219
18464
  };
18220
18465
  const [old, next] = await Promise.all([resolve(comparison.old), resolve(comparison.new)]);
18221
18466
  let oldSha = old;
18222
- if (comparison.mergeBase) {
18467
+ if (comparison.base === "merge-base") {
18223
18468
  const [a, b] = await Promise.all([old === "worktree" ? resolve("HEAD") : old, next === "worktree" ? resolve("HEAD") : next]);
18224
18469
  try {
18225
18470
  oldSha = await repo.mergeBase(a, b);
@@ -18228,6 +18473,13 @@ async function resolveComparison(repo, comparison) {
18228
18473
  throw e;
18229
18474
  }
18230
18475
  }
18476
+ if (comparison.base === "parent") {
18477
+ if (next === "worktree") throw new RevspecError("the worktree is not a commit");
18478
+ oldSha = await repo.resolve(`${next}^`).catch((e) => {
18479
+ if (e instanceof GitError && e.code === 1) return repo.emptyTree();
18480
+ throw e;
18481
+ });
18482
+ }
18231
18483
  const pinned = (rev, sha) => rev.length >= 7 && sha.startsWith(rev.toLowerCase());
18232
18484
  return {
18233
18485
  oldSha,
@@ -18241,8 +18493,18 @@ async function resolveReview(req, repo, github) {
18241
18493
  const parsed = req.kind === "working" ? {
18242
18494
  old: "HEAD",
18243
18495
  new: "worktree",
18244
- mergeBase: false
18496
+ base: "direct"
18245
18497
  } : parseRevspec(req.args);
18498
+ if (parsed.base === "parent") {
18499
+ const { newSha: sha } = await resolveComparison(repo, parsed);
18500
+ return { mode: {
18501
+ old: sha,
18502
+ new: sha,
18503
+ base: "parent",
18504
+ live: "none",
18505
+ commentKey: `commit:${sha}`
18506
+ } };
18507
+ }
18246
18508
  const { oldSha, newSha, live } = await resolveComparison(repo, parsed);
18247
18509
  return { mode: {
18248
18510
  ...parsed,
@@ -18252,19 +18514,21 @@ async function resolveReview(req, repo, github) {
18252
18514
  }
18253
18515
  /**
18254
18516
  * Branch identities keep review state stable across pushes and local branch aliases.
18517
+ * Keys read like the comparison, with ... for a merge base and .. otherwise.
18255
18518
  * For origin = github.com/o/r and HEAD on feat tracking origin/feat:
18256
- * origin/main...HEAD → branches:["o/r:main","o/r:feat",true]
18257
- * origin/main..HEAD → branches:["o/r:main","o/r:feat",false]
18519
+ * origin/main...HEAD → range:o/r:main...o/r:feat
18520
+ * origin/main..HEAD → range:o/r:main..o/r:feat
18258
18521
  * The matching explicit PR uses the first key too. If feat tracks a fork,
18259
18522
  * its identity becomes e.g. "contributor/r:feat", keeping forks distinct.
18260
18523
  *
18261
18524
  * Without a GitHub upstream, branches use full refs:
18262
- * main...feat → branches:["refs/heads/main","refs/heads/feat",true]
18525
+ * main...feat → range:refs/heads/main...refs/heads/feat
18263
18526
  * Non-branch endpoints use their resolved hash or literal "worktree":
18264
- * main..worktree → branches:["refs/heads/main","worktree",false]
18265
- * If neither endpoint is a branch, the key is revspec:<oldSha>..<newSha>.
18527
+ * main..worktree → range:refs/heads/main..worktree
18528
+ * <C>..<E> → range:<C hash>..<E hash>
18266
18529
  * Hash placeholders denote full resolved hashes, with oldSha already adjusted
18267
- * for mergeBase. The working command bypasses this helper and uses "working".
18530
+ * for the merge base. The working command bypasses this helper and uses
18531
+ * "working"; a single commit uses "commit:<hash>".
18268
18532
  */
18269
18533
  async function commentKey(repo, comparison, oldSha, newSha) {
18270
18534
  const remotes = await repo.remotes();
@@ -18278,14 +18542,10 @@ async function commentKey(repo, comparison, oldSha, newSha) {
18278
18542
  return repo.branchRef(rev);
18279
18543
  };
18280
18544
  const [old, next] = await Promise.all([identity(comparison.old), identity(comparison.new)]);
18281
- return old || next ? branchKey(old ?? oldSha, next ?? newSha, comparison.mergeBase) : `revspec:${oldSha}..${newSha}`;
18545
+ return rangeKey(old ?? oldSha, next ?? newSha, comparison.base === "merge-base");
18282
18546
  }
18283
- function branchKey(old, next, mergeBase) {
18284
- return `branches:${JSON.stringify([
18285
- old,
18286
- next,
18287
- mergeBase
18288
- ])}`;
18547
+ function rangeKey(old, next, mergeBase) {
18548
+ return `range:${old}${mergeBase ? "..." : ".."}${next}`;
18289
18549
  }
18290
18550
  /**
18291
18551
  * A GitHub pull request, as GitHub shows it: merge-base(base tip, head) vs head.
@@ -18304,16 +18564,16 @@ async function resolvePr(req, repo, github) {
18304
18564
  await resolveComparison(repo, {
18305
18565
  old: base,
18306
18566
  new: head,
18307
- mergeBase: true
18567
+ base: "merge-base"
18308
18568
  });
18309
18569
  return {
18310
18570
  prUrl: pr.url,
18311
18571
  mode: {
18312
18572
  old: base,
18313
18573
  new: head,
18314
- mergeBase: true,
18574
+ base: "merge-base",
18315
18575
  live: "none",
18316
- commentKey: branchKey(`${pr.repository.toLowerCase()}:${pr.baseRefName}`, `${pr.headRepository?.toLowerCase() ?? `deleted:${pr.url}`}:${pr.headRefName}`, true)
18576
+ commentKey: rangeKey(`${pr.repository.toLowerCase()}:${pr.baseRefName}`, `${pr.headRepository?.toLowerCase() ?? `deleted:${pr.url}`}:${pr.headRefName}`, true)
18317
18577
  }
18318
18578
  };
18319
18579
  }
@@ -18462,7 +18722,12 @@ var Snapshotter = class {
18462
18722
  }
18463
18723
  async compute(version) {
18464
18724
  const [{ oldSha, newSha }, headSha] = await Promise.all([resolveComparison(this.repo, this.mode), this.repo.resolve("HEAD").catch(() => "")]);
18465
- const [tracked, changed] = await Promise.all([newSha === "worktree" ? Promise.all([this.repo.lsFiles(), this.repo.untracked()]).then((lists) => lists.flat().filter((p) => !p.endsWith("/"))) : this.repo.lsTree(newSha), this.repo.numstat(oldSha, newSha)]);
18725
+ const [tracked, changed, commit, commits] = await Promise.all([
18726
+ newSha === "worktree" ? Promise.all([this.repo.lsFiles(), this.repo.untracked()]).then((lists) => lists.flat().filter((p) => !p.endsWith("/"))) : this.repo.lsTree(newSha),
18727
+ this.repo.numstat(oldSha, newSha),
18728
+ this.mode.base === "parent" ? this.reviewedCommit(newSha) : null,
18729
+ this.rangeCommits(oldSha, newSha, headSha)
18730
+ ]);
18466
18731
  await this.fillGenerated(changed, newSha);
18467
18732
  const tree = new Set(tracked);
18468
18733
  for (const f of changed) {
@@ -18479,11 +18744,36 @@ var Snapshotter = class {
18479
18744
  headSha,
18480
18745
  context: this.context,
18481
18746
  changed,
18482
- tree: [...tree].sort()
18747
+ tree: [...tree].sort(),
18748
+ commit,
18749
+ commits
18483
18750
  };
18484
18751
  for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
18485
18752
  return snap;
18486
18753
  }
18754
+ async reviewedCommit(sha) {
18755
+ const [info, neighbours] = await Promise.all([this.repo.commitInfo(sha), this.repo.commitNeighbours(sha)]);
18756
+ return info && {
18757
+ ...info,
18758
+ ...neighbours
18759
+ };
18760
+ }
18761
+ async rangeCommits(oldSha, newSha, headSha) {
18762
+ if (this.mode.within) {
18763
+ const range = await resolveComparison(this.repo, this.mode.within);
18764
+ return this.spanned(range.oldSha, range.newSha, headSha);
18765
+ }
18766
+ if (this.mode.base === "parent") return this.repo.rangeCommits(`${newSha}^!`, 250);
18767
+ return this.spanned(oldSha, newSha, headSha);
18768
+ }
18769
+ spanned(oldSha, newSha, headSha) {
18770
+ const [from, to] = [oldSha, newSha].map((sha) => sha === "worktree" ? headSha : sha);
18771
+ if (!from || !to || from === to) return Promise.resolve({
18772
+ list: [],
18773
+ total: 0
18774
+ });
18775
+ return this.repo.rangeCommits(`${from}..${to}`, 250);
18776
+ }
18487
18777
  /**
18488
18778
  * Sets `generated` on each non-binary changed file. A `linguist-generated`
18489
18779
  * gitattribute on the new side decides outright, as on GitHub; otherwise a
@@ -18634,10 +18924,17 @@ var Session = class {
18634
18924
  console.error("[diffle] snapshot listener failed:", e);
18635
18925
  }
18636
18926
  }
18637
- /** First mode. Rejects `ready()` on failure so early requests error out. */
18638
- async start(req) {
18927
+ /**
18928
+ * Resolves a request into a review without activating it, so a caller can reject
18929
+ * invalid input before serving. Queued, so `close()` waits for a PR fetch.
18930
+ */
18931
+ resolve(req) {
18932
+ return this.run(() => resolveReview(req, this.repo, this.opts.github));
18933
+ }
18934
+ /** First mode, from `resolve`. Rejects `ready()` on failure so early requests error out. */
18935
+ async start(review) {
18639
18936
  try {
18640
- const snap = await this.enter(req);
18937
+ const snap = await this.run(() => this.activate(review));
18641
18938
  this.resolveReady();
18642
18939
  return snap;
18643
18940
  } catch (e) {
@@ -18647,7 +18944,7 @@ var Session = class {
18647
18944
  }
18648
18945
  /** Switch modes at runtime. Broadcasts a snapshot bump on success. */
18649
18946
  async switchMode(req) {
18650
- const snap = await this.enter(req);
18947
+ const snap = await this.run(async () => this.activate(req.kind === "focus" ? await this.focus(req.commit) : await resolveReview(req, this.repo, this.opts.github)));
18651
18948
  this.hub.broadcast({
18652
18949
  type: "snapshot",
18653
18950
  version: snap.version
@@ -18655,6 +18952,33 @@ var Session = class {
18655
18952
  return snap;
18656
18953
  }
18657
18954
  /**
18955
+ * Focuses `commit`, by hash or short hash, of the active range's listed commits, or with null returns to
18956
+ * the range. A focused commit keeps its own comments, as when entered alone, and the range's PR identity.
18957
+ */
18958
+ async focus(commit) {
18959
+ const a = this.require();
18960
+ const { within, ...self } = a.mode;
18961
+ if (!within && self.base === "parent") throw new RevspecError("a single commit has no commits to focus");
18962
+ const range = within ?? self;
18963
+ if (commit === null) return {
18964
+ mode: range,
18965
+ prUrl: a.prUrl
18966
+ };
18967
+ const sha = (await a.snapshotter.current()).commits.list.find((c) => c.sha === commit || c.short === commit)?.sha;
18968
+ if (!sha) throw new RevspecError(`not a listed commit of ${comparisonLabel(range)}: ${commit}`);
18969
+ return {
18970
+ prUrl: a.prUrl,
18971
+ mode: {
18972
+ old: sha,
18973
+ new: sha,
18974
+ base: "parent",
18975
+ live: range.live === "none" ? "none" : "refs",
18976
+ commentKey: `commit:${sha}`,
18977
+ within: range
18978
+ }
18979
+ };
18980
+ }
18981
+ /**
18658
18982
  * Runs `fn` after every earlier mutation settled. The only way to touch
18659
18983
  * `active`, `version` or `opts.context`.
18660
18984
  */
@@ -18663,11 +18987,7 @@ var Session = class {
18663
18987
  this.queue = r.catch(() => {});
18664
18988
  return r;
18665
18989
  }
18666
- enter(req) {
18667
- return this.run(() => this.transition(req));
18668
- }
18669
- async transition(req) {
18670
- const { mode, prUrl } = await resolveReview(req, this.repo, this.opts.github);
18990
+ async activate({ mode, prUrl }) {
18671
18991
  const snapshotter = new Snapshotter(this.repo, mode, ++this.version, this.opts.context);
18672
18992
  const [comments, snap] = await Promise.all([CommentStore.open(this.repo.gitDir, mode.commentKey), snapshotter.current()]);
18673
18993
  const next = {
@@ -18760,6 +19080,18 @@ var Session = class {
18760
19080
  if (side === "new") return snap.newSha === "worktree" ? this.repo.readWorktree(target) : this.repo.show(snap.newSha, target);
18761
19081
  return snap.oldSha === "worktree" ? this.repo.readWorktree(target) : this.repo.show(snap.oldSha, target);
18762
19082
  }
19083
+ /**
19084
+ * The bytes `key` names on one side of `path`, or null unless `key` is that side's `imageKey` in `snap`.
19085
+ * A worktree file that changed since `snap` reads as null too, so a key never serves other bytes.
19086
+ */
19087
+ async readImage(snap, path, side, key) {
19088
+ if (key !== imageKey(snap, path, side)) return null;
19089
+ if (!snap.changed.some((f) => f.path === path)) return this.readSide(snap, path, key === snap.newSha ? "new" : "old");
19090
+ const buf = await this.readSide(snap, path, side);
19091
+ if (buf == null || (side === "new" ? snap.newSha : snap.oldSha) !== "worktree") return buf;
19092
+ const target = sidePath(this.readablePaths(snap), path, side);
19093
+ return target != null && await this.repo.hashObject(buf, target) === key ? buf : null;
19094
+ }
18763
19095
  /** Whether `readSide` would find `path` on `side`: the allowlist alone, no read. */
18764
19096
  hasSide(snap, path, side) {
18765
19097
  return sidePath(this.readablePaths(snap), path, side) != null;
@@ -22763,6 +23095,7 @@ Shorthands (in place of <revs>):
22763
23095
  working same as HEAD..worktree: uncommitted changes, staged and untracked included
22764
23096
  pr [number|url] a GitHub pull request: its base...head, fetched if needed
22765
23097
  (without an argument: the pull request for this branch)
23098
+ show [rev] same as <rev>^!: one commit against its first parent, pinned (default: HEAD)
22766
23099
 
22767
23100
  Revisions follow git diff, except that a lone one compares from the merge base:
22768
23101
  diffle main same as main...HEAD: what this branch added since it left main
@@ -22770,6 +23103,7 @@ Revisions follow git diff, except that a lone one compares from the merge base:
22770
23103
  diffle main..feat main vs feat
22771
23104
  diffle main...feat what feat added since it left main
22772
23105
  diffle main..worktree main vs the uncommitted tree ("worktree" works on either side)
23106
+ diffle a1b2c3d^! the commit a1b2c3d alone, like git show
22773
23107
 
22774
23108
  Without revisions, diffle compares HEAD..worktree.
22775
23109
 
@@ -22782,6 +23116,10 @@ Ctrl+C always stops it.`).action(async (revs, _o, cmd) => {
22782
23116
  }, cmd.optsWithGlobals());
22783
23117
  });
22784
23118
  program.command("working", { hidden: true }).summary("same as HEAD..worktree").description("Same as `diffle HEAD..worktree`: uncommitted changes, staged and untracked files included.").action(async (_o, cmd) => run({ kind: "working" }, cmd.optsWithGlobals()));
23119
+ program.command("show", { hidden: true }).argument("[rev]", "the commit to review", "HEAD").summary("one commit").description("Same as `diffle <rev>^!`: one commit against its first parent, as git show shows it.").action(async (rev, _o, cmd) => run({
23120
+ kind: "revspec",
23121
+ args: [`${rev}^!`]
23122
+ }, cmd.optsWithGlobals()));
22785
23123
  program.command("pr", { hidden: true }).argument("[pr]", "pull request number or url; default: the pull request for this branch").summary("a GitHub pull request").description("A GitHub pull request, as GitHub shows it: merge-base(base, head) vs head.").action(async (pr, _o, cmd) => run({
22786
23124
  kind: "pr",
22787
23125
  pr
@@ -22917,6 +23255,9 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
22917
23255
  process.on("SIGTERM", shutdown);
22918
23256
  stopBrowserWatch = watchBrowserLifetime(hub, opts.open && !opts.keepAlive, shutdown);
22919
23257
  try {
23258
+ const review = await session.resolve(req);
23259
+ timing.mark("resolve");
23260
+ if (closing) return;
22920
23261
  const url = await server.listen();
22921
23262
  timing.mark("listen");
22922
23263
  if (opts.open) {
@@ -22928,7 +23269,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
22928
23269
  console.error(`📂 ${c.dim("repo")} ${repo.root}`);
22929
23270
  if (github.client) console.error(`🐙 ${c.dim("github")} ${c.dim(`token from ${github.source}`)}`);
22930
23271
  else console.error(`🐙 ${c.dim("github")} ${c.dim("off: no token in GITHUB_TOKEN, GH_TOKEN or gh auth token")}`);
22931
- const snap = await session.start(req);
23272
+ const snap = await session.start(review);
22932
23273
  timing.mark("snapshot");
22933
23274
  const n = snap.changed.length;
22934
23275
  const adds = snap.changed.reduce((a, f) => a + f.additions, 0);