@moritzwilksch/diffle 0.1.8 → 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 +88 -131
  2. package/dist/client/assets/{angular-html-C3AR-WKY.js → angular-html-CJY2n6PT.js} +1 -1
  3. package/dist/client/assets/{angular-ts-bZzNmyh2.js → angular-ts-CZTM8_xo.js} +1 -1
  4. package/dist/client/assets/{apl-DKjQ1uUu.js → apl-Dcp77-Tz.js} +1 -1
  5. package/dist/client/assets/{astro-D6YBrldj.js → astro-C0gdsdoF.js} +1 -1
  6. package/dist/client/assets/{blade-QC3ty25r.js → blade-BYpI0fWU.js} +1 -1
  7. package/dist/client/assets/{c-CEfiNUIv.js → c-DvFVe1b3.js} +1 -1
  8. package/dist/client/assets/{chapel-XvVgmIw8.js → chapel-qAWjGK-V.js} +1 -1
  9. package/dist/client/assets/{cobol-Cyvb7uZ8.js → cobol-BeFQKGES.js} +1 -1
  10. package/dist/client/assets/{coffee-DKSUNEAJ.js → coffee-XLni1sjM.js} +1 -1
  11. package/dist/client/assets/{cpp-DvHlatt5.js → cpp-ChsJNYBC.js} +1 -1
  12. package/dist/client/assets/{crystal-BfxbaHfR.js → crystal-D3SbG8yD.js} +1 -1
  13. package/dist/client/assets/{css-CvzcDzFO.js → css-VXnGhPbY.js} +1 -1
  14. package/dist/client/assets/{edge-rOBhrNV9.js → edge-Dbzmpk4W.js} +1 -1
  15. package/dist/client/assets/{elixir-D3B6PlMg.js → elixir-DvQULd_0.js} +1 -1
  16. package/dist/client/assets/{elm-BnfJhLi3.js → elm-BM5mpULp.js} +1 -1
  17. package/dist/client/assets/{erb-CkLdBLG4.js → erb-nYCgf5TS.js} +1 -1
  18. package/dist/client/assets/{git-rebase-wqI2zI5G.js → git-rebase-CcDsi2L8.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-CorAzX48.js → glimmer-js-94igFmPU.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-DphgSML7.js → glimmer-ts-8aOBlhVe.js} +1 -1
  21. package/dist/client/assets/{glsl-Mt2MgDCN.js → glsl-DAW_sXZj.js} +1 -1
  22. package/dist/client/assets/{graphql-CR58itij.js → graphql-BEeRZxhy.js} +1 -1
  23. package/dist/client/assets/{hack-BYLE2FB3.js → hack-e4pIPigB.js} +1 -1
  24. package/dist/client/assets/{haml-Bo5vpKMC.js → haml-DvZH4eyc.js} +1 -1
  25. package/dist/client/assets/{handlebars-Cd6w9Qte.js → handlebars-Bod10MIb.js} +1 -1
  26. package/dist/client/assets/{html-EAc4fezP.js → html-BOKVgXUz.js} +1 -1
  27. package/dist/client/assets/{html-derivative-BCmkEMhH.js → html-derivative-B5DftdEs.js} +1 -1
  28. package/dist/client/assets/{http-dQXVRwNo.js → http-Bli9WHgJ.js} +1 -1
  29. package/dist/client/assets/{hurl-CTzL1YrB.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-C6GmB9Ie.js → java-BxezylI4.js} +1 -1
  33. package/dist/client/assets/{javascript-C3Xp2ZJG.js → javascript-CGUJoh42.js} +1 -1
  34. package/dist/client/assets/{jinja-Dm5uNF1A.js → jinja-Dhrq1TMH.js} +1 -1
  35. package/dist/client/assets/{jison-DWN1PAPH.js → jison-CK02w6aE.js} +1 -1
  36. package/dist/client/assets/{json-CD4Jl2TN.js → json-DbjMq68q.js} +1 -1
  37. package/dist/client/assets/{jsx-Dk_mg_Sq.js → jsx-BVYjBFnX.js} +1 -1
  38. package/dist/client/assets/{julia-ChQLeZ-J.js → julia-DUG0dPfw.js} +1 -1
  39. package/dist/client/assets/{just-Dw1MNaQ7.js → just-BzILq64e.js} +1 -1
  40. package/dist/client/assets/{latex-CHuqntlZ.js → latex-DJ-r-_gD.js} +1 -1
  41. package/dist/client/assets/{liquid-CdqvKBd5.js → liquid-CUeuBds8.js} +1 -1
  42. package/dist/client/assets/{lua-DtCcLDnQ.js → lua-CUx56wFu.js} +1 -1
  43. package/dist/client/assets/{marko-COZ6SLmm.js → marko-zidFMx4y.js} +1 -1
  44. package/dist/client/assets/{mdc-BTrsC_q_.js → mdc-B8YDeE-G.js} +1 -1
  45. package/dist/client/assets/{nginx-B_JY_bIl.js → nginx-CLmnmibq.js} +1 -1
  46. package/dist/client/assets/{nim-C7Boj9eE.js → nim-D_ow61_F.js} +1 -1
  47. package/dist/client/assets/{org-CYdh74lA.js → org-D6yFID6y.js} +1 -1
  48. package/dist/client/assets/{perl-CRiTh3Pi.js → perl-DlRNqEum.js} +1 -1
  49. package/dist/client/assets/{php-DSko-lNI.js → php-CPe77eH-.js} +1 -1
  50. package/dist/client/assets/{pug-BfUPln3e.js → pug-CzDq8hOp.js} +1 -1
  51. package/dist/client/assets/{qml-2jRMRran.js → qml-CmVqwUZS.js} +1 -1
  52. package/dist/client/assets/{r-sDW5nKEb.js → r-CZql-PDg.js} +1 -1
  53. package/dist/client/assets/{razor-Sa7GlYLb.js → razor-Dp5yql5G.js} +1 -1
  54. package/dist/client/assets/{regexp-AWVIUt-C.js → regexp-DjyDhvtX.js} +1 -1
  55. package/dist/client/assets/{rst-D20G5Wbg.js → rst-B-Q6Ljb6.js} +1 -1
  56. package/dist/client/assets/{ruby-DevA-y7a.js → ruby-BQD6FS2h.js} +1 -1
  57. package/dist/client/assets/{sas-DRiLtfNK.js → sas-DDGcym6k.js} +1 -1
  58. package/dist/client/assets/{scss-Ca4Dfxoe.js → scss-DNPPx0rx.js} +1 -1
  59. package/dist/client/assets/{shellscript-BNnaYtXN.js → shellscript-CHWpWo83.js} +1 -1
  60. package/dist/client/assets/{shellsession-B0nV-fpO.js → shellsession-_KE7LUMO.js} +1 -1
  61. package/dist/client/assets/{soy-CDZEZMSk.js → soy-B57xVh7W.js} +1 -1
  62. package/dist/client/assets/{sql-s_6SuPDf.js → sql-8OXlDHCR.js} +1 -1
  63. package/dist/client/assets/{stata-CaVdvC6P.js → stata-C452rT5l.js} +1 -1
  64. package/dist/client/assets/{surrealql-DZ5TdMgT.js → surrealql-B5gNeOUk.js} +1 -1
  65. package/dist/client/assets/{svelte-BkFQboE2.js → svelte-Cv_BwjgR.js} +1 -1
  66. package/dist/client/assets/{templ-QUoUDGUH.js → templ-qZdOs42N.js} +1 -1
  67. package/dist/client/assets/{tex-89lMVAG3.js → tex-BK-MJpDM.js} +1 -1
  68. package/dist/client/assets/{ts-tags-B2u4oUPE.js → ts-tags-C5Q-Olrs.js} +1 -1
  69. package/dist/client/assets/{tsx-ftFKhLnx.js → tsx-DwPm0lid.js} +1 -1
  70. package/dist/client/assets/{twig-DQCCfpmq.js → twig-DKj0IG75.js} +1 -1
  71. package/dist/client/assets/{typescript-DhCWy8LC.js → typescript-BeWKsdVP.js} +1 -1
  72. package/dist/client/assets/{typst-N-Gv0lEU.js → typst-B7u-3Y0n.js} +1 -1
  73. package/dist/client/assets/{vue-C7rUB8rE.js → vue-BkC1F40W.js} +1 -1
  74. package/dist/client/assets/{vue-html-D-dp6lDI.js → vue-html-BeYXHyr2.js} +1 -1
  75. package/dist/client/assets/{vue-vine-DEpfajiz.js → vue-vine-ymsHpgRC.js} +1 -1
  76. package/dist/client/assets/{xml-BGDSWH1X.js → xml-B6M3xnfe.js} +1 -1
  77. package/dist/client/assets/{xsl-D8Xmp-zV.js → xsl-CSlbm6tK.js} +1 -1
  78. package/dist/client/assets/{yaml-B3GKVR80.js → yaml-fMwD58zK.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/main.js +258 -52
  81. package/package.json +5 -3
  82. package/dist/client/assets/index-BkCL4UDj.css +0 -2
  83. package/dist/client/assets/index-DWcJ2X-_.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.8";
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([
@@ -12099,14 +12161,26 @@ const ModeRequestSchema = discriminatedUnion("kind", [
12099
12161
  object({
12100
12162
  kind: literal("revspec"),
12101
12163
  args: string().array()
12164
+ }),
12165
+ object({
12166
+ kind: literal("focus"),
12167
+ commit: string().nullable()
12102
12168
  })
12103
12169
  ]);
12104
- /** A comparison; both endpoints accept a Git revision or "worktree". */
12105
- 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({
12106
12181
  old: string(),
12107
12182
  new: string(),
12108
- /** Compare merge-base(old, new) to new; worktree uses HEAD for the merge base. */
12109
- mergeBase: boolean(),
12183
+ base: ComparisonBaseSchema,
12110
12184
  live: _enum([
12111
12185
  "worktree",
12112
12186
  "refs",
@@ -12115,10 +12189,18 @@ const ModeSpecSchema = object({
12115
12189
  /** Fixed when the comparison is entered; discovery never changes comment storage. */
12116
12190
  commentKey: string()
12117
12191
  });
12118
- /** 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
+ */
12119
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)}^!`;
12120
12202
  const name = (rev) => rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
12121
- return `${name(mode.old)}${mode.mergeBase ? "..." : ".."}${name(mode.new)}`;
12203
+ return `${name(mode.old)}${mode.base === "merge-base" ? "..." : ".."}${name(mode.new)}`;
12122
12204
  }
12123
12205
  const GithubPullRequestSchema = object({
12124
12206
  repository: string(),
@@ -12168,6 +12250,32 @@ const ChangedFileSchema = object({
12168
12250
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
12169
12251
  submodule: literal(true).optional()
12170
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
+ });
12171
12279
  object({
12172
12280
  root: string(),
12173
12281
  mode: ModeSpecSchema,
@@ -12182,7 +12290,10 @@ object({
12182
12290
  context: number(),
12183
12291
  changed: ChangedFileSchema.array(),
12184
12292
  /** All paths on the new side: the new commit's tree, or index ∪ untracked for the worktree. Sorted. */
12185
- 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
12186
12297
  });
12187
12298
  /** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
12188
12299
  const PatchRequestSchema = object({ paths: string().array().max(200) });
@@ -12279,11 +12390,6 @@ const ViewedEntrySchema = object({
12279
12390
  blob: string(),
12280
12391
  viewed: boolean()
12281
12392
  });
12282
- const CommitInfoSchema = object({
12283
- sha: string(),
12284
- short: string(),
12285
- message: string()
12286
- });
12287
12393
  object({
12288
12394
  old: CommitInfoSchema.nullable(),
12289
12395
  new: CommitInfoSchema.nullable()
@@ -14044,6 +14150,7 @@ const WORKTREE = "worktree";
14044
14150
  * "a..b" → a vs b
14045
14151
  * "a...b" → merge-base(a, b) vs b
14046
14152
  * "a" "b" → a vs b
14153
+ * "a^!" → a vs its first parent: the one commit, as git show shows it
14047
14154
  * Empty sides default to HEAD. Either side accepts "worktree" for the uncommitted
14048
14155
  * tree; a merge base against it is taken against HEAD, the commit it sits on.
14049
14156
  */
@@ -14052,34 +14159,45 @@ function parseRevspec(args) {
14052
14159
  if (args.length === 2) {
14053
14160
  const [a, b] = args;
14054
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");
14055
14163
  return {
14056
14164
  old: a,
14057
14165
  new: b,
14058
- mergeBase: false
14166
+ base: "direct"
14059
14167
  };
14060
14168
  }
14061
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
+ }
14062
14180
  const three = arg.indexOf("...");
14063
14181
  if (three !== -1) return {
14064
14182
  old: arg.slice(0, three) || "HEAD",
14065
14183
  new: arg.slice(three + 3) || "HEAD",
14066
- mergeBase: true
14184
+ base: "merge-base"
14067
14185
  };
14068
14186
  const two = arg.indexOf("..");
14069
14187
  if (two !== -1) return {
14070
14188
  old: arg.slice(0, two) || "HEAD",
14071
14189
  new: arg.slice(two + 2) || "HEAD",
14072
- mergeBase: false
14190
+ base: "direct"
14073
14191
  };
14074
14192
  if (arg === WORKTREE) return {
14075
14193
  old: "HEAD",
14076
14194
  new: WORKTREE,
14077
- mergeBase: false
14195
+ base: "direct"
14078
14196
  };
14079
14197
  return {
14080
14198
  old: arg,
14081
14199
  new: "HEAD",
14082
- mergeBase: true
14200
+ base: "merge-base"
14083
14201
  };
14084
14202
  }
14085
14203
  var RevspecError = class extends Error {};
@@ -17705,7 +17823,8 @@ var Server = class {
17705
17823
  app.use("/api/*", compress());
17706
17824
  app.route("/", createApi(this.deps));
17707
17825
  app.all("/api/*", (c) => c.json({ error: "API endpoint not found. Restart diffle to load the latest server." }, 404));
17708
- if (!this.opts.dev) {
17826
+ if (!this.opts.dev && isSea()) app.get("*", serveEmbedded);
17827
+ else if (!this.opts.dev) {
17709
17828
  const clientDir = resolveClientDir();
17710
17829
  app.use("*", serveStatic({ root: clientDir }));
17711
17830
  app.get("*", serveStatic({
@@ -17792,7 +17911,14 @@ function resolveClientDir() {
17792
17911
  return join(projectRoot(), "dist", "client");
17793
17912
  }
17794
17913
  function hasClientBuild() {
17795
- 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 });
17796
17922
  }
17797
17923
  //#endregion
17798
17924
  //#region src/server/github/pulls.ts
@@ -18307,17 +18433,19 @@ function describe(skipped) {
18307
18433
  * A--B--C main
18308
18434
  * \--D--E HEAD
18309
18435
  *
18310
- * old new mergeBase oldSha newSha live
18311
- * main HEAD false C E refs
18312
- * main HEAD true B E refs
18313
- * HEAD worktree false E worktree worktree
18314
- * worktree HEAD false worktree E worktree
18315
- * main worktree true B worktree worktree
18316
- * <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
18317
18444
  *
18318
- * mergeBase replaces only the old endpoint with the common ancestor, using
18319
- * HEAD for any worktree input. live is "worktree" if either input is worktree,
18320
- * "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.
18321
18449
  *
18322
18450
  * In an unborn repository, HEAD compared directly with worktree resolves to
18323
18451
  * the empty tree. Invalid revisions or missing merge bases throw RevspecError.
@@ -18329,14 +18457,14 @@ async function resolveComparison(repo, comparison) {
18329
18457
  try {
18330
18458
  return await repo.resolve(rev);
18331
18459
  } catch (e) {
18332
- 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();
18333
18461
  if (e instanceof GitError) throw new RevspecError(`unknown revision: ${rev}`);
18334
18462
  throw e;
18335
18463
  }
18336
18464
  };
18337
18465
  const [old, next] = await Promise.all([resolve(comparison.old), resolve(comparison.new)]);
18338
18466
  let oldSha = old;
18339
- if (comparison.mergeBase) {
18467
+ if (comparison.base === "merge-base") {
18340
18468
  const [a, b] = await Promise.all([old === "worktree" ? resolve("HEAD") : old, next === "worktree" ? resolve("HEAD") : next]);
18341
18469
  try {
18342
18470
  oldSha = await repo.mergeBase(a, b);
@@ -18345,6 +18473,13 @@ async function resolveComparison(repo, comparison) {
18345
18473
  throw e;
18346
18474
  }
18347
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
+ }
18348
18483
  const pinned = (rev, sha) => rev.length >= 7 && sha.startsWith(rev.toLowerCase());
18349
18484
  return {
18350
18485
  oldSha,
@@ -18358,8 +18493,18 @@ async function resolveReview(req, repo, github) {
18358
18493
  const parsed = req.kind === "working" ? {
18359
18494
  old: "HEAD",
18360
18495
  new: "worktree",
18361
- mergeBase: false
18496
+ base: "direct"
18362
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
+ }
18363
18508
  const { oldSha, newSha, live } = await resolveComparison(repo, parsed);
18364
18509
  return { mode: {
18365
18510
  ...parsed,
@@ -18369,19 +18514,21 @@ async function resolveReview(req, repo, github) {
18369
18514
  }
18370
18515
  /**
18371
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.
18372
18518
  * For origin = github.com/o/r and HEAD on feat tracking origin/feat:
18373
- * origin/main...HEAD → branches:["o/r:main","o/r:feat",true]
18374
- * 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
18375
18521
  * The matching explicit PR uses the first key too. If feat tracks a fork,
18376
18522
  * its identity becomes e.g. "contributor/r:feat", keeping forks distinct.
18377
18523
  *
18378
18524
  * Without a GitHub upstream, branches use full refs:
18379
- * main...feat → branches:["refs/heads/main","refs/heads/feat",true]
18525
+ * main...feat → range:refs/heads/main...refs/heads/feat
18380
18526
  * Non-branch endpoints use their resolved hash or literal "worktree":
18381
- * main..worktree → branches:["refs/heads/main","worktree",false]
18382
- * 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>
18383
18529
  * Hash placeholders denote full resolved hashes, with oldSha already adjusted
18384
- * 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>".
18385
18532
  */
18386
18533
  async function commentKey(repo, comparison, oldSha, newSha) {
18387
18534
  const remotes = await repo.remotes();
@@ -18395,14 +18542,10 @@ async function commentKey(repo, comparison, oldSha, newSha) {
18395
18542
  return repo.branchRef(rev);
18396
18543
  };
18397
18544
  const [old, next] = await Promise.all([identity(comparison.old), identity(comparison.new)]);
18398
- return old || next ? branchKey(old ?? oldSha, next ?? newSha, comparison.mergeBase) : `revspec:${oldSha}..${newSha}`;
18545
+ return rangeKey(old ?? oldSha, next ?? newSha, comparison.base === "merge-base");
18399
18546
  }
18400
- function branchKey(old, next, mergeBase) {
18401
- return `branches:${JSON.stringify([
18402
- old,
18403
- next,
18404
- mergeBase
18405
- ])}`;
18547
+ function rangeKey(old, next, mergeBase) {
18548
+ return `range:${old}${mergeBase ? "..." : ".."}${next}`;
18406
18549
  }
18407
18550
  /**
18408
18551
  * A GitHub pull request, as GitHub shows it: merge-base(base tip, head) vs head.
@@ -18421,16 +18564,16 @@ async function resolvePr(req, repo, github) {
18421
18564
  await resolveComparison(repo, {
18422
18565
  old: base,
18423
18566
  new: head,
18424
- mergeBase: true
18567
+ base: "merge-base"
18425
18568
  });
18426
18569
  return {
18427
18570
  prUrl: pr.url,
18428
18571
  mode: {
18429
18572
  old: base,
18430
18573
  new: head,
18431
- mergeBase: true,
18574
+ base: "merge-base",
18432
18575
  live: "none",
18433
- 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)
18434
18577
  }
18435
18578
  };
18436
18579
  }
@@ -18579,7 +18722,12 @@ var Snapshotter = class {
18579
18722
  }
18580
18723
  async compute(version) {
18581
18724
  const [{ oldSha, newSha }, headSha] = await Promise.all([resolveComparison(this.repo, this.mode), this.repo.resolve("HEAD").catch(() => "")]);
18582
- 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
+ ]);
18583
18731
  await this.fillGenerated(changed, newSha);
18584
18732
  const tree = new Set(tracked);
18585
18733
  for (const f of changed) {
@@ -18596,11 +18744,36 @@ var Snapshotter = class {
18596
18744
  headSha,
18597
18745
  context: this.context,
18598
18746
  changed,
18599
- tree: [...tree].sort()
18747
+ tree: [...tree].sort(),
18748
+ commit,
18749
+ commits
18600
18750
  };
18601
18751
  for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
18602
18752
  return snap;
18603
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
+ }
18604
18777
  /**
18605
18778
  * Sets `generated` on each non-binary changed file. A `linguist-generated`
18606
18779
  * gitattribute on the new side decides outright, as on GitHub; otherwise a
@@ -18771,7 +18944,7 @@ var Session = class {
18771
18944
  }
18772
18945
  /** Switch modes at runtime. Broadcasts a snapshot bump on success. */
18773
18946
  async switchMode(req) {
18774
- const snap = await this.run(async () => this.activate(await resolveReview(req, this.repo, this.opts.github)));
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)));
18775
18948
  this.hub.broadcast({
18776
18949
  type: "snapshot",
18777
18950
  version: snap.version
@@ -18779,6 +18952,33 @@ var Session = class {
18779
18952
  return snap;
18780
18953
  }
18781
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
+ /**
18782
18982
  * Runs `fn` after every earlier mutation settled. The only way to touch
18783
18983
  * `active`, `version` or `opts.context`.
18784
18984
  */
@@ -22895,6 +23095,7 @@ Shorthands (in place of <revs>):
22895
23095
  working same as HEAD..worktree: uncommitted changes, staged and untracked included
22896
23096
  pr [number|url] a GitHub pull request: its base...head, fetched if needed
22897
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)
22898
23099
 
22899
23100
  Revisions follow git diff, except that a lone one compares from the merge base:
22900
23101
  diffle main same as main...HEAD: what this branch added since it left main
@@ -22902,6 +23103,7 @@ Revisions follow git diff, except that a lone one compares from the merge base:
22902
23103
  diffle main..feat main vs feat
22903
23104
  diffle main...feat what feat added since it left main
22904
23105
  diffle main..worktree main vs the uncommitted tree ("worktree" works on either side)
23106
+ diffle a1b2c3d^! the commit a1b2c3d alone, like git show
22905
23107
 
22906
23108
  Without revisions, diffle compares HEAD..worktree.
22907
23109
 
@@ -22914,6 +23116,10 @@ Ctrl+C always stops it.`).action(async (revs, _o, cmd) => {
22914
23116
  }, cmd.optsWithGlobals());
22915
23117
  });
22916
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()));
22917
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({
22918
23124
  kind: "pr",
22919
23125
  pr
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moritzwilksch/diffle",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Local git diff reviewer in the browser with line comments exported as agent prompts",
5
5
  "keywords": [
6
6
  "agent",
@@ -9,7 +9,7 @@
9
9
  "diff",
10
10
  "git"
11
11
  ],
12
- "homepage": "https://github.com/moritzwilksch/diffle#readme",
12
+ "homepage": "https://diffle.app",
13
13
  "bugs": {
14
14
  "url": "https://github.com/moritzwilksch/diffle/issues"
15
15
  },
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "bin",
27
27
  "dist",
28
+ "!dist/sea",
28
29
  "THIRD_PARTY_NOTICES.md"
29
30
  ],
30
31
  "type": "module",
@@ -37,6 +38,7 @@
37
38
  "build": "npm run build:client && npm run build:server",
38
39
  "build:client": "vite build",
39
40
  "build:server": "tsdown",
41
+ "build:sea": "npm run build && node scripts/build-sea.mjs",
40
42
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
41
43
  "lint": "oxlint",
42
44
  "format": "oxfmt",
@@ -45,7 +47,7 @@
45
47
  "test:e2e": "playwright test",
46
48
  "test:e2e:install": "playwright install chromium",
47
49
  "test:e2e:install-deps": "playwright install-deps chromium",
48
- "screenshot": "tsx",
50
+ "screenshot": "tsx scripts/screenshot.ts",
49
51
  "test:e2e:docker": "node scripts/e2e-docker.mjs",
50
52
  "test:e2e:docker:update": "node scripts/e2e-docker.mjs --update-snapshots",
51
53
  "test:e2e:report": "playwright show-report playwright-report",