@moritzwilksch/diffle 0.1.4 → 0.1.6

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 +25 -6
  2. package/dist/client/assets/{angular-html-DCs801AK.js → angular-html-VfoYZbqR.js} +1 -1
  3. package/dist/client/assets/{angular-ts-BWhN0w8_.js → angular-ts-E_ds17k3.js} +1 -1
  4. package/dist/client/assets/{apl-Djz0ErNW.js → apl-DfLg31Yo.js} +1 -1
  5. package/dist/client/assets/{astro-MWC3rLUN.js → astro-DDZGZEa0.js} +1 -1
  6. package/dist/client/assets/{blade-B8pycf1w.js → blade-C7j7NUSc.js} +1 -1
  7. package/dist/client/assets/{c-B-Jcxs--.js → c-B9ldfZRm.js} +1 -1
  8. package/dist/client/assets/{chapel-CuJCeKLf.js → chapel-D1rbgfGL.js} +1 -1
  9. package/dist/client/assets/{cobol-DgquORVs.js → cobol-CvvLWZ5H.js} +1 -1
  10. package/dist/client/assets/{coffee-ECOaIhT5.js → coffee-Cq5EY8Kf.js} +1 -1
  11. package/dist/client/assets/{cpp-CqnSivHO.js → cpp-KDnKhkos.js} +1 -1
  12. package/dist/client/assets/{crystal-Cs7ZYBfN.js → crystal-qnDGw82Q.js} +1 -1
  13. package/dist/client/assets/{css-p0qk9Lci.js → css-DbYR8NW9.js} +1 -1
  14. package/dist/client/assets/{edge-mMULBvUs.js → edge-BytNLpnp.js} +1 -1
  15. package/dist/client/assets/{elixir-VIosceup.js → elixir-BOIZTT6O.js} +1 -1
  16. package/dist/client/assets/{elm-C3kw87lA.js → elm-BLyo2uoF.js} +1 -1
  17. package/dist/client/assets/{erb-DZjmloke.js → erb-C8RG1ggg.js} +1 -1
  18. package/dist/client/assets/{git-rebase-qAMhScBw.js → git-rebase-BsJjDHkQ.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-DJdA7CEB.js → glimmer-js-F0nfRvQ4.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-6xtV4JQb.js → glimmer-ts-BaUh_0sm.js} +1 -1
  21. package/dist/client/assets/{glsl-BRqSJ3dN.js → glsl-r7jKENsf.js} +1 -1
  22. package/dist/client/assets/{graphql-nm_X3Hg7.js → graphql-DecxGIfy.js} +1 -1
  23. package/dist/client/assets/{hack-CQ7j-SLC.js → hack-D1QdSbsI.js} +1 -1
  24. package/dist/client/assets/{haml-nw30ZCFe.js → haml-DZ3Y8IEU.js} +1 -1
  25. package/dist/client/assets/{handlebars-CbbUPaYB.js → handlebars-9ij-Y0dS.js} +1 -1
  26. package/dist/client/assets/{html-C-E44L0y.js → html-_813EOsa.js} +1 -1
  27. package/dist/client/assets/{html-derivative-DTCnXXkR.js → html-derivative-DMoYXYJX.js} +1 -1
  28. package/dist/client/assets/{http-Cg4Jw-kz.js → http-BHZjBd1F.js} +1 -1
  29. package/dist/client/assets/{hurl-CITbwYVL.js → hurl-B9C7GLqi.js} +1 -1
  30. package/dist/client/assets/index-CDwn6vHT.css +2 -0
  31. package/dist/client/assets/{index-BdA8MWLx.js → index-DDePiSWW.js} +104 -93
  32. package/dist/client/assets/{java-DNT_EPVr.js → java-BXcKXYSC.js} +1 -1
  33. package/dist/client/assets/{javascript-DBrMK690.js → javascript-DzK8bvMk.js} +1 -1
  34. package/dist/client/assets/{jinja-DqPnwJo-.js → jinja-DkPJymJ8.js} +1 -1
  35. package/dist/client/assets/{jison-A5ieRkCJ.js → jison-BRfwGndr.js} +1 -1
  36. package/dist/client/assets/{json-BolWLdCd.js → json-CVsgTDXF.js} +1 -1
  37. package/dist/client/assets/{jsx-CkIysgAJ.js → jsx-CCEGQMKy.js} +1 -1
  38. package/dist/client/assets/{julia-jBzm267c.js → julia-DB5wqhUl.js} +1 -1
  39. package/dist/client/assets/{just-cr8UASbW.js → just-saVQlczz.js} +1 -1
  40. package/dist/client/assets/{latex-DcdC0vak.js → latex-C7pJ0jXy.js} +1 -1
  41. package/dist/client/assets/{liquid-BBR6eAOA.js → liquid-BzVhxXiW.js} +1 -1
  42. package/dist/client/assets/{lua-CnUVAA9w.js → lua-B68msXNV.js} +1 -1
  43. package/dist/client/assets/{marko-BxH5DxB_.js → marko-D0vTAOKD.js} +1 -1
  44. package/dist/client/assets/{mdc-CVc0_tcy.js → mdc-C5xXBRll.js} +1 -1
  45. package/dist/client/assets/{nginx-B1DQmN_N.js → nginx-BgUn926n.js} +1 -1
  46. package/dist/client/assets/{nim-C3rsdIhh.js → nim-BWiAEcU7.js} +1 -1
  47. package/dist/client/assets/{org-DICsbmv1.js → org-PxncFL6r.js} +1 -1
  48. package/dist/client/assets/{perl-B-6tgIaz.js → perl-DNCL3XMU.js} +1 -1
  49. package/dist/client/assets/{php-CSZf1YiM.js → php-B4HLiXa-.js} +1 -1
  50. package/dist/client/assets/{pug-BvQ97HKe.js → pug-YBRTFp5G.js} +1 -1
  51. package/dist/client/assets/{qml-B_gSWBeZ.js → qml-Kbw3ykka.js} +1 -1
  52. package/dist/client/assets/{r-Dbh43Ti8.js → r-e7ntBSQI.js} +1 -1
  53. package/dist/client/assets/{razor-Dks3_W39.js → razor-C-7kbnr6.js} +1 -1
  54. package/dist/client/assets/{regexp-cZE9On7Z.js → regexp-BfKQ26Bd.js} +1 -1
  55. package/dist/client/assets/{rst-Cgn-1Zks.js → rst-Dk61acH4.js} +1 -1
  56. package/dist/client/assets/{ruby-DkGM-R-6.js → ruby-B-Gsrm_0.js} +1 -1
  57. package/dist/client/assets/{sas-D6c_PGbn.js → sas-DrwY32Sv.js} +1 -1
  58. package/dist/client/assets/{scss-BKxWJNFh.js → scss-BQ0tGjl9.js} +1 -1
  59. package/dist/client/assets/{shellscript-Bcg5lXvK.js → shellscript-70AyB6AW.js} +1 -1
  60. package/dist/client/assets/{shellsession-Bf400BZo.js → shellsession-C0TRVWC2.js} +1 -1
  61. package/dist/client/assets/{soy-VDZj_scj.js → soy-ljPZKxfc.js} +1 -1
  62. package/dist/client/assets/{sql-ushyJdRf.js → sql-CpAh8MTM.js} +1 -1
  63. package/dist/client/assets/{stata-87RYcwm-.js → stata-U-vhcCzr.js} +1 -1
  64. package/dist/client/assets/{surrealql-DiJkNDgM.js → surrealql-Cq-W9D43.js} +1 -1
  65. package/dist/client/assets/{svelte-B6zEQCqX.js → svelte-CsPr3yNN.js} +1 -1
  66. package/dist/client/assets/{templ-BbUkdxgW.js → templ-DieGJMZV.js} +1 -1
  67. package/dist/client/assets/{tex-8DUlt0ja.js → tex-D0ERfPEs.js} +1 -1
  68. package/dist/client/assets/{ts-tags-B9TjJt8W.js → ts-tags-Bw9LC0mV.js} +1 -1
  69. package/dist/client/assets/{tsx-KtVv-1tC.js → tsx-DIXVqg6z.js} +1 -1
  70. package/dist/client/assets/{twig-SRZcBkws.js → twig-DbTBKLwc.js} +1 -1
  71. package/dist/client/assets/{typescript-XxBZhs1X.js → typescript-Bd6Plo3n.js} +1 -1
  72. package/dist/client/assets/{typst-zdrwuFs3.js → typst-DqDsanSi.js} +1 -1
  73. package/dist/client/assets/{vue-CIJzu_ou.js → vue-Da_CXJw9.js} +1 -1
  74. package/dist/client/assets/{vue-html-Cbh4Xclf.js → vue-html-n6FmJEJ_.js} +1 -1
  75. package/dist/client/assets/{vue-vine-B3rP3Gia.js → vue-vine-BOc7r2gI.js} +1 -1
  76. package/dist/client/assets/{xml-B8FuGGpH.js → xml-DzU5oF9g.js} +1 -1
  77. package/dist/client/assets/{xsl-DGb4ZSQs.js → xsl-BTxmEaay.js} +1 -1
  78. package/dist/client/assets/{yaml-DgFlGzOM.js → yaml-DYMpEUsY.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/{Watcher-C_X03LRa.js → Watcher-CKwvGuPB.js} +5 -4
  81. package/dist/server/main.js +343 -145
  82. package/package.json +1 -1
  83. package/dist/client/assets/index-CBL0EMlj.css +0 -2
@@ -3951,7 +3951,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3951
3951
  }
3952
3952
  //#endregion
3953
3953
  //#region package.json
3954
- var version$1 = "0.1.4";
3954
+ var version$1 = "0.1.6";
3955
3955
  //#endregion
3956
3956
  //#region src/server/comments/anchor.ts
3957
3957
  function splitLines(contents) {
@@ -3996,10 +3996,15 @@ function quoteRange(contents, startLine, endLine) {
3996
3996
  if (startLine < 1 || endLine < startLine || endLine > lines.length) return null;
3997
3997
  return lines.slice(startLine - 1, endLine).join("\n");
3998
3998
  }
3999
- /** Review order: by path, then line, then when the thread was opened. */
3999
+ /** The line a thread sorts under: a file thread comes before every line of its file. */
4000
+ function anchorLine(anchor) {
4001
+ return anchor.kind === "line" ? anchor.startLine : 0;
4002
+ }
4003
+ /** Review order: by path, then line (file threads first), then when the thread was opened. */
4000
4004
  function compareThreads(a, b) {
4001
4005
  if (a.anchor.path !== b.anchor.path) return a.anchor.path < b.anchor.path ? -1 : 1;
4002
- if (a.anchor.startLine !== b.anchor.startLine) return a.anchor.startLine - b.anchor.startLine;
4006
+ const line = anchorLine(a.anchor) - anchorLine(b.anchor);
4007
+ if (line !== 0) return line;
4003
4008
  return (a.messages[0]?.createdAt ?? 0) - (b.messages[0]?.createdAt ?? 0);
4004
4009
  }
4005
4010
  //#endregion
@@ -4007,21 +4012,23 @@ function compareThreads(a, b) {
4007
4012
  /**
4008
4013
  * Agent-prompt format, one block per thread:
4009
4014
  *
4010
- * path:line (or path:start-end)
4015
+ * path:line (or path:start-end; `(file) path` for a thread on the whole file)
4011
4016
  *
4012
- * > quoted line
4017
+ * > quoted line (absent for a file thread)
4013
4018
  *
4014
4019
  * body (a thread's messages joined by a blank line)
4015
4020
  *
4016
4021
  * ---
4017
4022
  *
4018
- * A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences.
4023
+ * A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences; a file
4024
+ * thread has no original, so its fences stay as written.
4019
4025
  */
4020
4026
  function formatPrompt(threads) {
4021
4027
  return [...threads].sort(compareThreads).map(formatOne).join("\n");
4022
4028
  }
4023
4029
  function formatOne(t) {
4024
4030
  const { anchor } = t;
4031
+ if (anchor.kind === "file") return `${`(file) ${t.stale ? "(stale) " : ""}${anchor.path}`}\n\n${t.messages.map((m) => m.body.trim()).join("\n\n")}\n\n---\n`;
4025
4032
  const range = anchor.startLine === anchor.endLine ? `${anchor.startLine}` : `${anchor.startLine}-${anchor.endLine}`;
4026
4033
  const prefixes = [];
4027
4034
  if (anchor.side === "old") prefixes.push("(removed)");
@@ -4513,10 +4520,11 @@ var GitRepo = class GitRepo {
4513
4520
  * Worktree searches include untracked files. The result is
4514
4521
  * bounded globally: git is stopped once `limit + 1` records have arrived, so a
4515
4522
  * common query in a large repository cannot flood the process. `paths`
4516
- * restricts the search to those files; an empty list matches nothing.
4523
+ * restricts files; `ranges` restricts new-side lines before applying the limit.
4524
+ * An empty path list or range map matches nothing.
4517
4525
  */
4518
4526
  async grep(query, rev, limit = 500, opts = {}) {
4519
- if (!query || opts.paths?.length === 0) return {
4527
+ if (!query || opts.paths?.length === 0 || opts.ranges?.size === 0) return {
4520
4528
  matches: [],
4521
4529
  truncated: false
4522
4530
  };
@@ -4526,9 +4534,9 @@ var GitRepo = class GitRepo {
4526
4534
  "--no-column",
4527
4535
  "-I",
4528
4536
  opts.regex ? "-E" : "-F",
4529
- "-z",
4530
- `--max-count=${limit + 1}`
4537
+ "-z"
4531
4538
  ];
4539
+ if (!opts.ranges) args.push(`--max-count=${limit + 1}`);
4532
4540
  if (opts.word) args.push("-w");
4533
4541
  if (opts.ignoreCase) args.push("-i");
4534
4542
  args.push("-e", query);
@@ -4536,20 +4544,25 @@ var GitRepo = class GitRepo {
4536
4544
  else args.push(rev);
4537
4545
  args.push("--");
4538
4546
  for (const p of opts.paths ?? []) args.push(`:(literal)${p}`);
4547
+ const parse = (rec) => {
4548
+ const [rawPath = "", line = "", ...rest] = rec.split("\0");
4549
+ return {
4550
+ path: rev === "worktree" ? rawPath : rawPath.slice(rawPath.indexOf(":") + 1),
4551
+ line: Number(line),
4552
+ text: rest.join("\0").slice(0, 300)
4553
+ };
4554
+ };
4539
4555
  const records = await execGitRecords(this.root, args, limit + 1, {
4540
4556
  okCodes: [0, 1],
4541
- timeoutMs: GREP_TIMEOUT_MS
4557
+ timeoutMs: GREP_TIMEOUT_MS,
4558
+ accept: opts.ranges ? (record) => {
4559
+ const { path, line } = parse(record);
4560
+ return opts.ranges.get(path)?.some(([start, end]) => line >= start && line <= end) ?? false;
4561
+ } : void 0
4542
4562
  });
4543
4563
  const truncated = records.length > limit;
4544
4564
  return {
4545
- matches: records.slice(0, limit).map((rec) => {
4546
- const [rawPath = "", line = "", ...rest] = rec.split("\0");
4547
- return {
4548
- path: rev === "worktree" ? rawPath : rawPath.slice(rawPath.indexOf(":") + 1),
4549
- line: Number(line),
4550
- text: rest.join("\0").slice(0, 300)
4551
- };
4552
- }),
4565
+ matches: records.slice(0, limit).map(parse),
4553
4566
  truncated
4554
4567
  };
4555
4568
  }
@@ -4720,6 +4733,31 @@ var GitRepo = class GitRepo {
4720
4733
  await fh?.close();
4721
4734
  }
4722
4735
  }
4736
+ /**
4737
+ * Boolean value of a gitattribute per path: `attr` or `attr=true` reads
4738
+ * true, `-attr` or `attr=false` false; unspecified paths are absent. A
4739
+ * revision reads `.gitattributes` from that tree, the worktree from the
4740
+ * checkout. `--source` needs git 2.40.
4741
+ */
4742
+ async attr(name, paths, rev) {
4743
+ const out = /* @__PURE__ */ new Map();
4744
+ if (paths.length === 0) return out;
4745
+ const source = rev === "worktree" ? [] : [`--source=${rev}`];
4746
+ const fields = splitZ(await this.exec([
4747
+ "check-attr",
4748
+ "-z",
4749
+ "--stdin",
4750
+ ...source,
4751
+ name,
4752
+ "--"
4753
+ ], { input: paths.join("\0") }));
4754
+ for (let i = 0; i + 2 < fields.length; i += 3) {
4755
+ const value = fields[i + 2];
4756
+ if (value === "set" || value === "true") out.set(fields[i], true);
4757
+ else if (value === "unset" || value === "false") out.set(fields[i], false);
4758
+ }
4759
+ return out;
4760
+ }
4723
4761
  /** Paths git ignores, from a candidate list. */
4724
4762
  async ignored(paths) {
4725
4763
  if (paths.length === 0) return /* @__PURE__ */ new Set();
@@ -4951,9 +4989,10 @@ function execGitRecords(cwd, args, maxRecords, opts = {}) {
4951
4989
  GIT_OPTIONAL_LOCKS: "0"
4952
4990
  }
4953
4991
  });
4954
- const chunks = [];
4992
+ const records = [];
4993
+ let chunks = [];
4994
+ let fields = 0;
4955
4995
  const errChunks = [];
4956
- let seen = 0;
4957
4996
  let done = false;
4958
4997
  let timedOut = false;
4959
4998
  const timer = opts.timeoutMs == null ? null : setTimeout(() => {
@@ -4962,16 +5001,24 @@ function execGitRecords(cwd, args, maxRecords, opts = {}) {
4962
5001
  }, opts.timeoutMs);
4963
5002
  child.stdout.on("data", (chunk) => {
4964
5003
  if (done) return;
5004
+ let start = 0;
4965
5005
  for (let i = 0; i < chunk.length; i++) {
4966
- if (chunk[i] !== 10) continue;
4967
- if (++seen === maxRecords) {
4968
- chunks.push(chunk.subarray(0, i + 1));
5006
+ if (chunk[i] === 0) fields++;
5007
+ if (chunk[i] !== 10 || fields < 2) continue;
5008
+ chunks.push(chunk.subarray(start, i));
5009
+ const record = Buffer.concat(chunks).toString("utf8");
5010
+ chunks = [];
5011
+ fields = 0;
5012
+ start = i + 1;
5013
+ if (opts.accept && !opts.accept(record)) continue;
5014
+ records.push(record);
5015
+ if (records.length === maxRecords) {
4969
5016
  done = true;
4970
5017
  child.kill();
4971
5018
  return;
4972
5019
  }
4973
5020
  }
4974
- chunks.push(chunk);
5021
+ if (start < chunk.length) chunks.push(chunk.subarray(start));
4975
5022
  });
4976
5023
  child.stderr.on("data", (chunk) => errChunks.push(chunk));
4977
5024
  child.on("error", (err) => rej(new GitError(`git ${args.join(" ")} failed: ${err.message}`, args, null, "")));
@@ -4986,8 +5033,6 @@ function execGitRecords(cwd, args, maxRecords, opts = {}) {
4986
5033
  rej(new GitError(`git ${args.join(" ")} failed: ${stderr.trim()}`, args, code, stderr));
4987
5034
  return;
4988
5035
  }
4989
- const records = Buffer.concat(chunks).toString("utf8").split("\n");
4990
- if (records[records.length - 1] === "") records.pop();
4991
5036
  res(records);
4992
5037
  });
4993
5038
  });
@@ -5076,11 +5121,15 @@ var GithubError = class extends Error {
5076
5121
  this.status = status;
5077
5122
  }
5078
5123
  };
5124
+ function isFileComment(c) {
5125
+ return "subject_type" in c;
5126
+ }
5079
5127
  /**
5080
5128
  * Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
5081
5129
  * their lines no longer sit in the diff, and GitHub would refuse them anyway. With
5082
5130
  * `threadIds`, only those (resolved included, the user asked for them by hand);
5083
- * without, every unresolved thread.
5131
+ * without, every unresolved thread. `review.comments` holds the line comments REST can
5132
+ * create; the file comments are appended afterwards, but `ids` counts both, in review order.
5084
5133
  */
5085
5134
  function buildReview(threads, commitId, threadIds) {
5086
5135
  const skipped = [];
@@ -5113,20 +5162,27 @@ function buildReview(threads, commitId, threadIds) {
5113
5162
  return {
5114
5163
  review: {
5115
5164
  commit_id: commitId,
5116
- comments
5165
+ comments: comments.filter((c) => !isFileComment(c))
5117
5166
  },
5167
+ comments,
5118
5168
  ids,
5119
5169
  skipped
5120
5170
  };
5121
5171
  }
5122
5172
  function toReviewComment(t) {
5123
5173
  const { anchor } = t;
5174
+ const body = formatBody(t.messages);
5175
+ if (anchor.kind === "file") return {
5176
+ path: anchor.path,
5177
+ subject_type: "file",
5178
+ body
5179
+ };
5124
5180
  const side = anchor.side === "old" ? "LEFT" : "RIGHT";
5125
5181
  const c = {
5126
5182
  path: anchor.path,
5127
5183
  line: anchor.endLine,
5128
5184
  side,
5129
- body: formatBody(t.messages)
5185
+ body
5130
5186
  };
5131
5187
  if (anchor.startLine !== anchor.endLine) {
5132
5188
  c.start_line = anchor.startLine;
@@ -5171,15 +5227,25 @@ var GithubExporter = class {
5171
5227
  */
5172
5228
  async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run = runGh }) {
5173
5229
  if (snap.newSha === "worktree") throw new GithubError("GitHub cannot anchor comments to the worktree");
5174
- const { review, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
5175
- if (review.comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
5176
- for (const [i, comment] of review.comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
5230
+ const { review, comments, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
5231
+ if (comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
5232
+ for (const [i, comment] of comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
5177
5233
  const pending = await findPendingReview(run, snap.root, pr);
5178
5234
  if (!pending) {
5179
- await createPendingReview(run, snap.root, pr.repository, pr.number, review);
5235
+ const created = await createPendingReview(run, snap.root, pr.repository, pr.number, review);
5236
+ let appended = 0;
5237
+ try {
5238
+ for (const c of comments.filter(isFileComment)) {
5239
+ await addToPendingReview(run, snap.root, created.id, c);
5240
+ appended++;
5241
+ }
5242
+ } catch (e) {
5243
+ const detail = e instanceof Error ? e.message : String(e);
5244
+ throw new GithubError(`created the pending review with ${review.comments.length + appended} comments, then ${detail}`, 502);
5245
+ }
5180
5246
  return {
5181
5247
  url: pr.url,
5182
- posted: review.comments.length,
5248
+ posted: comments.length,
5183
5249
  updated: 0,
5184
5250
  review: "created",
5185
5251
  skipped
@@ -5190,7 +5256,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run =
5190
5256
  let updated = 0;
5191
5257
  let posted = 0;
5192
5258
  try {
5193
- for (const [i, c] of review.comments.entries()) {
5259
+ for (const [i, c] of comments.entries()) {
5194
5260
  const id = ids[i];
5195
5261
  const at = existing.get(anchorKey(c));
5196
5262
  const index = at?.findIndex((comment) => comment.body.trimEnd().endsWith(threadMarker(id))) ?? -1;
@@ -5234,6 +5300,7 @@ function threadMarker(id) {
5234
5300
  }
5235
5301
  /** The anchor must still match before we rewrite an exported thread. */
5236
5302
  function anchorKey(c) {
5303
+ if (isFileComment(c)) return [c.path, "FILE"].join("\0");
5237
5304
  return [
5238
5305
  c.path,
5239
5306
  c.side,
@@ -5264,7 +5331,7 @@ async function findPendingReview(run, cwd, pr) {
5264
5331
  const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
5265
5332
  repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
5266
5333
  pageInfo{hasNextPage endCursor}
5267
- nodes{path line startLine diffSide comments(first:1){nodes{id body pullRequestReview{id}}}}
5334
+ nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
5268
5335
  }}}
5269
5336
  }`;
5270
5337
  /**
@@ -5288,15 +5355,8 @@ async function pendingComments(run, cwd, pr, reviewId) {
5288
5355
  for (const t of threads.nodes) {
5289
5356
  const c = t?.comments?.nodes?.[0];
5290
5357
  if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
5291
- const line = t.line;
5292
- if (line == null) continue;
5293
- const side = t.diffSide === "LEFT" ? "LEFT" : "RIGHT";
5294
- const key = anchorKey({
5295
- path: t.path,
5296
- side,
5297
- line,
5298
- start_line: t.startLine ?? void 0
5299
- });
5358
+ const key = pendingKey(t);
5359
+ if (key == null) continue;
5300
5360
  const at = by.get(key);
5301
5361
  if (at) at.push({
5302
5362
  nodeId: c.id,
@@ -5313,10 +5373,30 @@ async function pendingComments(run, cwd, pr, reviewId) {
5313
5373
  }
5314
5374
  throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
5315
5375
  }
5376
+ /** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
5377
+ function pendingKey(t) {
5378
+ const path = t.path;
5379
+ if (t.subjectType === "FILE") return anchorKey({
5380
+ path,
5381
+ subject_type: "file",
5382
+ body: ""
5383
+ });
5384
+ if (t.line == null) return null;
5385
+ return anchorKey({
5386
+ path,
5387
+ side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
5388
+ line: t.line,
5389
+ start_line: t.startLine ?? void 0,
5390
+ body: ""
5391
+ });
5392
+ }
5316
5393
  const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
5317
- /** No `event` in the body, so GitHub keeps the new review pending with all of its comments. */
5394
+ /**
5395
+ * No `event` in the body, so GitHub keeps the new review pending with all of its comments.
5396
+ * Resolves the review's node id, for the comments REST cannot carry.
5397
+ */
5318
5398
  async function createPendingReview(run, cwd, repository, number, review) {
5319
- await run([
5399
+ const out = await run([
5320
5400
  "api",
5321
5401
  "--method",
5322
5402
  "POST",
@@ -5327,20 +5407,31 @@ async function createPendingReview(run, cwd, repository, number, review) {
5327
5407
  cwd,
5328
5408
  input: JSON.stringify(review)
5329
5409
  });
5410
+ let id;
5411
+ try {
5412
+ id = JSON.parse(out).node_id;
5413
+ } catch {
5414
+ throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
5415
+ }
5416
+ if (typeof id !== "string") throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
5417
+ return { id };
5330
5418
  }
5331
5419
  const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
5332
- /** Appends one thread through GraphQL; REST cannot extend a pending review. */
5420
+ /** Appends one thread through GraphQL; REST cannot extend a pending review, nor place a file comment. */
5333
5421
  async function addToPendingReview(run, cwd, reviewId, c) {
5334
5422
  const input = {
5335
5423
  pullRequestReviewId: reviewId,
5336
5424
  path: c.path,
5337
- line: c.line,
5338
- side: c.side,
5339
5425
  body: c.body
5340
5426
  };
5341
- if (c.start_line != null) {
5342
- input.startLine = c.start_line;
5343
- input.startSide = c.start_side;
5427
+ if (isFileComment(c)) input.subjectType = "FILE";
5428
+ else {
5429
+ input.line = c.line;
5430
+ input.side = c.side;
5431
+ if (c.start_line != null) {
5432
+ input.startLine = c.start_line;
5433
+ input.startSide = c.start_side;
5434
+ }
5344
5435
  }
5345
5436
  await graphql(run, cwd, ADD_THREAD, { input });
5346
5437
  }
@@ -11164,7 +11255,7 @@ const ChangedFileSchema = object({
11164
11255
  binary: boolean(),
11165
11256
  /** New-side blob sha. '' for deletions. Keys the viewed state. */
11166
11257
  blob: string(),
11167
- /** A path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
11258
+ /** The `linguist-generated` gitattribute, a path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
11168
11259
  generated: boolean(),
11169
11260
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
11170
11261
  submodule: literal(true).optional()
@@ -11192,7 +11283,8 @@ object({
11192
11283
  contents: string(),
11193
11284
  binary: boolean()
11194
11285
  });
11195
- const CommentAnchorSchema = object({
11286
+ const CommentAnchorSchema = discriminatedUnion("kind", [object({
11287
+ kind: literal("line"),
11196
11288
  path: string(),
11197
11289
  /** Column the user selected in a diff; 'new' for file items. */
11198
11290
  side: SideSchema,
@@ -11200,7 +11292,11 @@ const CommentAnchorSchema = object({
11200
11292
  endLine: number(),
11201
11293
  /** Exact text of [startLine, endLine] at creation. */
11202
11294
  quoted: string()
11203
- });
11295
+ }), object({
11296
+ kind: literal("file"),
11297
+ /** The changed file's path (its new path for a rename), or any tree path. */
11298
+ path: string()
11299
+ })]);
11204
11300
  const CommentMessageSchema = object({
11205
11301
  id: string(),
11206
11302
  body: string(),
@@ -11214,24 +11310,31 @@ object({
11214
11310
  messages: CommentMessageSchema.array().min(1),
11215
11311
  resolved: boolean(),
11216
11312
  resolvedAt: number().optional(),
11217
- /** Relocation failed after a snapshot refresh. */
11313
+ /** Relocation failed after a snapshot refresh: the lines left the diff, or the file left the review. */
11218
11314
  stale: boolean(),
11219
- /** Original startLine, shown in export when stale. */
11315
+ /** Original startLine of a line thread, shown in export when stale. */
11220
11316
  staleFromLine: number().optional()
11221
11317
  });
11222
- /** Create payload. `quoted` is optional: the server quotes the range from the snapshot. */
11318
+ /**
11319
+ * Create payload. Without `startLine` the thread is on the file as a whole; then `side`,
11320
+ * `endLine` and `quoted` are refused rather than ignored, so a payload that meant a line
11321
+ * never lands on the file. `quoted` is optional: the server quotes the range from the snapshot.
11322
+ */
11223
11323
  const ThreadCreateSchema = object({
11224
11324
  path: string().min(1),
11225
11325
  /** Default 'new'. */
11226
11326
  side: SideSchema.optional(),
11227
- startLine: number().int().positive(),
11327
+ startLine: number().int().positive().optional(),
11228
11328
  /** Default startLine. */
11229
11329
  endLine: number().int().positive().optional(),
11230
11330
  body: string().refine((body) => body.trim().length > 0, "body required"),
11231
11331
  quoted: string().optional()
11232
- }).refine((t) => t.endLine == null || t.endLine >= t.startLine, {
11332
+ }).refine((t) => t.startLine == null || t.endLine == null || t.endLine >= t.startLine, {
11233
11333
  path: ["endLine"],
11234
11334
  message: "endLine must be ≥ startLine"
11335
+ }).refine((t) => t.startLine != null || t.side == null && t.endLine == null && t.quoted == null, {
11336
+ path: ["startLine"],
11337
+ message: "side, endLine and quoted need a startLine; leave all four out for a comment on the whole file"
11235
11338
  });
11236
11339
  const ReplyCreateSchema = object({ body: string().refine((body) => body.trim().length > 0, "body required") });
11237
11340
  const ThreadStateSchema = _enum([
@@ -11301,6 +11404,8 @@ const SearchScopeSchema = _enum([
11301
11404
  "diff",
11302
11405
  "repo"
11303
11406
  ]);
11407
+ /** Whether search includes whole files or only new-side diff hunks and their context. */
11408
+ const SearchContentSchema = _enum(["diff", "full"]);
11304
11409
  object({
11305
11410
  query: string(),
11306
11411
  matches: SearchMatchSchema.array(),
@@ -11522,6 +11627,7 @@ const flagSchema = _enum(["0", "1"]).optional().transform((value) => value === "
11522
11627
  const SearchQuerySchema = object({
11523
11628
  q: string().default(""),
11524
11629
  scope: SearchScopeSchema.default("diff"),
11630
+ content: SearchContentSchema.default("diff"),
11525
11631
  path: string().optional(),
11526
11632
  word: flagSchema,
11527
11633
  i: flagSchema,
@@ -15780,6 +15886,7 @@ const LOOPBACK = /* @__PURE__ */ new Set([
15780
15886
  "::1",
15781
15887
  "localhost"
15782
15888
  ]);
15889
+ const HINT = "if a reverse proxy sent it, trust that origin with --allowed-origin";
15783
15890
  /**
15784
15891
  * Restricts API and WebSocket requests to local hosts and the configured public origin.
15785
15892
  * Proxies may preserve the public Host or rewrite it to an allowed upstream Host.
@@ -15794,20 +15901,24 @@ function requestGuard(bindHost, names = ownNames(), allowedOrigin) {
15794
15901
  ]);
15795
15902
  const publicHost = allowedOrigin == null ? void 0 : new URL(allowedOrigin).host;
15796
15903
  return ({ host, origin }) => {
15797
- if (!host) return false;
15904
+ if (!host) return "missing Host header";
15798
15905
  const name = hostnameOf(host).toLowerCase();
15799
15906
  const localHost = loopback ? LOOPBACK.has(name) : isIP(name) !== 0 || allowed.has(name);
15800
15907
  const proxyHost = publicHost != null && host.toLowerCase() === publicHost;
15801
- if (!localHost && !proxyHost) return false;
15802
- if (origin == null || allowedOrigin != null && origin === allowedOrigin) return true;
15803
- if (proxyHost) return false;
15804
- try {
15805
- return new URL(origin).host === host;
15806
- } catch {
15807
- return false;
15808
- }
15908
+ if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${HINT}`;
15909
+ if (origin == null || allowedOrigin != null && origin === allowedOrigin) return null;
15910
+ if (!proxyHost && parseHost(origin) === host) return null;
15911
+ return `forbidden Origin "${origin}" for Host "${host}"; ${HINT}`;
15809
15912
  };
15810
15913
  }
15914
+ /** The host of a syntactically valid origin; undefined for `null` and other non-URLs. */
15915
+ function parseHost(origin) {
15916
+ try {
15917
+ return new URL(origin).host;
15918
+ } catch {
15919
+ return;
15920
+ }
15921
+ }
15811
15922
  /** The machine's hostname, its short form, and its mDNS name, so LAN visitors can use any of them. */
15812
15923
  function ownNames() {
15813
15924
  const full = hostname();
@@ -15877,15 +15988,17 @@ function toThreadCreate(entry, where) {
15877
15988
  }
15878
15989
  /** True when `t` duplicates an open thread at the same anchor whose opening message has the same body. */
15879
15990
  function isDuplicate(existing, t) {
15991
+ const body = t.body.trim();
15992
+ const open = existing.filter((e) => !e.resolved && e.anchor.path === t.path && e.messages[0]?.body.trim() === body);
15993
+ if (t.startLine == null) return open.some((e) => e.anchor.kind === "file");
15880
15994
  const side = t.side ?? "new";
15881
15995
  const endLine = t.endLine ?? t.startLine;
15882
- const body = t.body.trim();
15883
- return existing.some((e) => !e.resolved && e.anchor.path === t.path && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine && e.messages[0]?.body.trim() === body);
15996
+ return open.some((e) => e.anchor.kind === "line" && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine);
15884
15997
  }
15885
15998
  //#endregion
15886
15999
  //#region src/server/comments/CommentStore.ts
15887
16000
  var NotFoundError = class extends Error {};
15888
- /** A range the snapshot cannot quote: path absent on that side, or lines past the end. */
16001
+ /** An anchor the snapshot cannot place: path absent on that side, or lines past the end. */
15889
16002
  var UnquotableError = class extends Error {};
15890
16003
  /**
15891
16004
  * Owns this mode's set in `<gitDir>/diffle/comments.json`: threads and viewed
@@ -15902,14 +16015,14 @@ var CommentStore = class CommentStore {
15902
16015
  this.file = file;
15903
16016
  this.key = key;
15904
16017
  }
15905
- /** Opens the store; a v1 file is backed up to `comments.v1.bak` and rewritten as v2 first. */
16018
+ /** Opens the store; an older file is backed up to `comments.v<n>.bak` and rewritten in the current format first. */
15906
16019
  static async open(gitDir, modeKey) {
15907
16020
  const file = join(gitDir, "diffle", "comments.json");
15908
16021
  await withFileLock(file, async () => {
15909
16022
  const all = await readStoreFile(file);
15910
- if (all.version !== 1) return;
15911
- await copyFile(file, join(gitDir, "diffle", "comments.v1.bak"));
15912
- await writeStoreFile(file, migrateV1(all));
16023
+ if (all.version === 3) return;
16024
+ await copyFile(file, join(gitDir, "diffle", `comments.v${all.version}.bak`));
16025
+ await writeStoreFile(file, upgrade(all));
15913
16026
  });
15914
16027
  return new CommentStore(file, modeKey);
15915
16028
  }
@@ -15985,12 +16098,13 @@ var CommentStore = class CommentStore {
15985
16098
  });
15986
16099
  }
15987
16100
  /**
15988
- * Adds each import that is not already an open duplicate. Every range is
15989
- * checked against the snapshot through `quote`; a payload's own `quoted` (the
15990
- * text the browser showed) is kept once the range is known to exist. Throws
15991
- * UnquotableError, adding nothing, when any range cannot be read.
16101
+ * Adds each import that is not already an open duplicate. Every anchor is
16102
+ * checked against the snapshot through `source`: a range is quoted (a payload's
16103
+ * own `quoted`, the text the browser showed, is kept once the range is known to
16104
+ * exist) and a file thread's path must be in the review. Throws UnquotableError,
16105
+ * adding nothing, when any anchor cannot be placed.
15992
16106
  */
15993
- importThreads(imports, quote) {
16107
+ importThreads(imports, source) {
15994
16108
  return this.mutate(async (set) => {
15995
16109
  const added = [];
15996
16110
  const pending = [];
@@ -16001,18 +16115,7 @@ var CommentStore = class CommentStore {
16001
16115
  skipped++;
16002
16116
  continue;
16003
16117
  }
16004
- const side = t.side ?? "new";
16005
- const startLine = t.startLine;
16006
- const endLine = t.endLine ?? startLine;
16007
- const fromSnapshot = await quote(t.path, side, startLine, endLine);
16008
- if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
16009
- const anchor = {
16010
- path: t.path,
16011
- side,
16012
- startLine,
16013
- endLine,
16014
- quoted: t.quoted ?? fromSnapshot
16015
- };
16118
+ const anchor = await placeImport(t, source);
16016
16119
  pending.push({
16017
16120
  anchor,
16018
16121
  t
@@ -16093,17 +16196,18 @@ var CommentStore = class CommentStore {
16093
16196
  });
16094
16197
  }
16095
16198
  /**
16096
- * Re-anchor every thread against the current review. A thread is stale when
16097
- * its side is gone, its text is not found, or the relocated range falls
16098
- * outside what the review shows. Returns true if anything changed.
16199
+ * Re-anchor every thread against the current review. A line thread is stale
16200
+ * when its side is gone, its text is not found, or the relocated range falls
16201
+ * outside what the review shows; a file thread when its file left the review.
16202
+ * Returns true if anything changed.
16099
16203
  */
16100
- async relocateAll(view) {
16204
+ async relocateAll(review) {
16101
16205
  const cache = /* @__PURE__ */ new Map();
16102
16206
  const load = (path, side) => {
16103
16207
  const k = `${side}:${path}`;
16104
16208
  let p = cache.get(k);
16105
16209
  if (!p) {
16106
- p = view(path, side);
16210
+ p = review.side(path, side);
16107
16211
  cache.set(k, p);
16108
16212
  }
16109
16213
  return p;
@@ -16111,6 +16215,14 @@ var CommentStore = class CommentStore {
16111
16215
  return this.mutate(async (set) => {
16112
16216
  let changed = false;
16113
16217
  for (const t of set.threads) {
16218
+ if (t.anchor.kind === "file") {
16219
+ const stale = !review.hasFile(t.anchor.path);
16220
+ if (stale !== t.stale) {
16221
+ t.stale = stale;
16222
+ changed = true;
16223
+ }
16224
+ continue;
16225
+ }
16114
16226
  const v = await load(t.anchor.path, t.anchor.side);
16115
16227
  let moved = v == null ? null : relocate(t.anchor, v.contents);
16116
16228
  if (moved && v?.shown && !isShown(v.shown, moved.startLine, moved.endLine)) moved = null;
@@ -16147,7 +16259,7 @@ var CommentStore = class CommentStore {
16147
16259
  viewed: []
16148
16260
  };
16149
16261
  }
16150
- return ownSet(parsed.version === 2 ? parsed : migrateV1(parsed), this.key);
16262
+ return ownSet(upgrade(parsed), this.key);
16151
16263
  }
16152
16264
  /**
16153
16265
  * Locked read-modify-write: `fn` edits this key's set as read from disk under
@@ -16157,12 +16269,11 @@ var CommentStore = class CommentStore {
16157
16269
  */
16158
16270
  mutate(fn, changed = () => true) {
16159
16271
  return withFileLock(this.file, async () => {
16160
- const onDisk = await readStoreFile(this.file);
16161
- const all = onDisk.version === 2 ? onDisk : migrateV1(onDisk);
16272
+ const all = upgrade(await readStoreFile(this.file));
16162
16273
  const set = ownSet(all, this.key);
16163
16274
  const r = await fn(set);
16164
16275
  if (changed(r)) await writeStoreFile(this.file, {
16165
- version: 2,
16276
+ version: 3,
16166
16277
  sets: {
16167
16278
  ...all.sets,
16168
16279
  [this.key]: set
@@ -16184,7 +16295,33 @@ function findThread(set, id) {
16184
16295
  if (!t) throw new NotFoundError(id);
16185
16296
  return t;
16186
16297
  }
16187
- /** The file as stored, or an empty v2 store when absent. Malformed JSON propagates. */
16298
+ /**
16299
+ * The anchor an import lands on, checked against the snapshot. Throws
16300
+ * UnquotableError for a range that cannot be quoted or a file the review lacks.
16301
+ */
16302
+ async function placeImport(t, source) {
16303
+ if (t.startLine == null) {
16304
+ if (!await source.hasFile(t.path)) throw new UnquotableError(`${t.path} is not in the review`);
16305
+ return {
16306
+ kind: "file",
16307
+ path: t.path
16308
+ };
16309
+ }
16310
+ const side = t.side ?? "new";
16311
+ const startLine = t.startLine;
16312
+ const endLine = t.endLine ?? startLine;
16313
+ const fromSnapshot = await source.quote(t.path, side, startLine, endLine);
16314
+ if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
16315
+ return {
16316
+ kind: "line",
16317
+ path: t.path,
16318
+ side,
16319
+ startLine,
16320
+ endLine,
16321
+ quoted: t.quoted ?? fromSnapshot
16322
+ };
16323
+ }
16324
+ /** The file as stored, or an empty store when absent. Malformed JSON propagates. */
16188
16325
  async function readStoreFile(file) {
16189
16326
  try {
16190
16327
  return parseStoreFile(await readFile(file, "utf8"));
@@ -16192,20 +16329,26 @@ async function readStoreFile(file) {
16192
16329
  if (e.code !== "ENOENT") throw e;
16193
16330
  }
16194
16331
  return {
16195
- version: 2,
16332
+ version: 3,
16196
16333
  sets: {}
16197
16334
  };
16198
16335
  }
16199
- /** Unknown shapes read as an empty v2 store. */
16336
+ /** Unknown shapes read as an empty store. */
16200
16337
  function parseStoreFile(text) {
16201
16338
  const parsed = JSON.parse(text);
16339
+ if (parsed.version === 3 && parsed.sets) return parsed;
16202
16340
  if (parsed.version === 2 && parsed.sets) return parsed;
16203
16341
  if (parsed.version === 1 && parsed.sets) return parsed;
16204
16342
  return {
16205
- version: 2,
16343
+ version: 3,
16206
16344
  sets: {}
16207
16345
  };
16208
16346
  }
16347
+ /** Any stored format as the current one. */
16348
+ function upgrade(all) {
16349
+ if (all.version === 3) return all;
16350
+ return migrateV2(all.version === 2 ? all : migrateV1(all));
16351
+ }
16209
16352
  /** Atomic write. Quoted source lines: owner-only. */
16210
16353
  function writeStoreFile(file, data) {
16211
16354
  return writeFileAtomic(file, JSON.stringify(data, null, 2), 384);
@@ -16219,6 +16362,24 @@ function newMessage(msg) {
16219
16362
  updatedAt: now
16220
16363
  };
16221
16364
  }
16365
+ /** Every v2 anchor is a line range; v3 says so. */
16366
+ function migrateV2(v2) {
16367
+ const sets = {};
16368
+ for (const [key, set] of Object.entries(v2.sets)) sets[key] = {
16369
+ threads: (set.threads ?? []).map((t) => ({
16370
+ ...t,
16371
+ anchor: {
16372
+ kind: "line",
16373
+ ...t.anchor
16374
+ }
16375
+ })),
16376
+ viewed: set.viewed ?? []
16377
+ };
16378
+ return {
16379
+ version: 3,
16380
+ sets
16381
+ };
16382
+ }
16222
16383
  /** Each v1 comment becomes a single-message, unresolved thread. */
16223
16384
  function migrateV1(v1) {
16224
16385
  const sets = {};
@@ -16313,12 +16474,22 @@ function createApi(deps) {
16313
16474
  const query = SearchQuerySchema.parse(c.req.query());
16314
16475
  const { q, scope } = query;
16315
16476
  const snap = await session.snapshotter.current();
16316
- const paths = scope === "repo" ? void 0 : scope === "file" ? snap.tree.filter((p) => p === query.path) : snap.changed.filter((f) => f.status !== "D").map((f) => f.path);
16477
+ let paths = scope === "repo" ? void 0 : scope === "file" ? snap.tree.filter((p) => p === query.path) : snap.changed.filter((f) => f.status !== "D").map((f) => f.path);
16478
+ let ranges;
16479
+ if (query.content === "diff") {
16480
+ const entries = await mapLimit(snap.changed.filter((f) => f.status !== "D" && !f.binary && (!paths || paths.includes(f.path))), 8, async (file) => {
16481
+ const patch = await session.repo.patch(snap.oldSha, snap.newSha, file, snap.context);
16482
+ return [file.path, shownRanges(patch, "new")];
16483
+ });
16484
+ ranges = new Map(entries);
16485
+ paths = entries.filter(([, spans]) => spans.length > 0).map(([path]) => path);
16486
+ }
16317
16487
  const { matches, truncated } = await session.repo.grep(q, snap.newSha, 500, {
16318
16488
  word: query.word,
16319
16489
  ignoreCase: query.i,
16320
16490
  regex: query.re,
16321
- paths
16491
+ paths,
16492
+ ranges
16322
16493
  });
16323
16494
  const body = {
16324
16495
  query: q,
@@ -16349,7 +16520,7 @@ function createApi(deps) {
16349
16520
  });
16350
16521
  app.post("/api/threads", async (c) => {
16351
16522
  const imports = parseImports(await readJson(c));
16352
- const { added } = await session.comments.importThreads(imports, session.quoter());
16523
+ const { added } = await session.comments.importThreads(imports, session.anchorSource());
16353
16524
  if (added.length) hub.broadcast({ type: "threads" });
16354
16525
  return c.json(added, 201);
16355
16526
  });
@@ -16499,10 +16670,11 @@ var Server = class {
16499
16670
  const app = new Hono();
16500
16671
  const guard = requestGuard(this.opts.host, void 0, this.opts.allowedOrigin);
16501
16672
  app.use("/api/*", async (c, next) => {
16502
- if (!guard({
16673
+ const rejected = guard({
16503
16674
  host: c.req.header("host"),
16504
16675
  origin: c.req.header("origin")
16505
- })) return c.json({ error: "forbidden origin" }, 403);
16676
+ });
16677
+ if (rejected != null) return c.json({ error: rejected }, 403);
16506
16678
  await next();
16507
16679
  });
16508
16680
  app.use("/api/*", async (c, next) => {
@@ -16892,6 +17064,8 @@ function looksGenerated(path, head) {
16892
17064
  //#endregion
16893
17065
  //#region src/server/Snapshotter.ts
16894
17066
  const PREWARM = 3;
17067
+ /** The gitattribute GitHub reads to collapse generated files; `=false` opts a file out of the heuristics. */
17068
+ const GENERATED_ATTR = "linguist-generated";
16895
17069
  /** Concurrent worktree reads while flagging generated files. */
16896
17070
  const SNIFF_CONCURRENCY = 8;
16897
17071
  /** Sniff verdicts remembered by blob sha; the map is emptied, not evicted, past this. */
@@ -17000,14 +17174,23 @@ var Snapshotter = class {
17000
17174
  return snap;
17001
17175
  }
17002
17176
  /**
17003
- * Sets `generated` on each non-binary changed file: by path first, then by a
17004
- * SNIFF_BYTES content sniff. Blobs already classified are not read again;
17005
- * commit-side blobs go through one `cat-file --batch`, worktree files through
17006
- * a bounded read each. Never fails the snapshot: a read error means false.
17177
+ * Sets `generated` on each non-binary changed file. A `linguist-generated`
17178
+ * gitattribute on the new side decides outright, as on GitHub; otherwise a
17179
+ * path convention, then a SNIFF_BYTES content sniff. Blobs already sniffed
17180
+ * are not read again; commit-side blobs go through one `cat-file --batch`,
17181
+ * worktree files through a bounded read each. Never fails the snapshot: a
17182
+ * failed attribute lookup or read means the next heuristic, then false.
17007
17183
  * Submodules have no contents to sniff.
17008
17184
  */
17009
17185
  async fillGenerated(files, newSha) {
17010
- const sniff = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule).filter((f) => !(f.generated = looksGenerated(f.path, null)));
17186
+ const textual = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule);
17187
+ const marked = await this.repo.attr(GENERATED_ATTR, textual.map((f) => f.path), newSha).catch(() => /* @__PURE__ */ new Map());
17188
+ const sniff = [];
17189
+ for (const f of textual) {
17190
+ const attr = marked.get(f.path);
17191
+ if (attr != null) f.generated = attr;
17192
+ else if (!(f.generated = looksGenerated(f.path, null))) sniff.push(f);
17193
+ }
17011
17194
  const unknown = [];
17012
17195
  for (const f of sniff) {
17013
17196
  const known = f.blob ? this.sniffed.get(f.blob) : void 0;
@@ -17232,21 +17415,24 @@ var Session = class {
17232
17415
  /**
17233
17416
  * Re-anchors threads against what `snap` shows. A changed file shows its
17234
17417
  * hunks; an unchanged tree file shows its new side whole (the file view) and
17235
- * nothing of its old side.
17418
+ * nothing of its old side. A file thread only needs its file in the review.
17236
17419
  */
17237
17420
  async relocateComments(a, snap) {
17238
- await a.comments.relocateAll(async (path, side) => {
17239
- const buf = await this.readSide(snap, path, side);
17240
- if (buf == null) return null;
17241
- const contents = buf.toString("utf8");
17242
- if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
17243
- contents,
17244
- shown: null
17245
- } : null;
17246
- return {
17247
- contents,
17248
- shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
17249
- };
17421
+ await a.comments.relocateAll({
17422
+ side: async (path, side) => {
17423
+ const buf = await this.readSide(snap, path, side);
17424
+ if (buf == null) return null;
17425
+ const contents = buf.toString("utf8");
17426
+ if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
17427
+ contents,
17428
+ shown: null
17429
+ } : null;
17430
+ return {
17431
+ contents,
17432
+ shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
17433
+ };
17434
+ },
17435
+ hasFile: (path) => this.hasFile(snap, path)
17250
17436
  });
17251
17437
  }
17252
17438
  /**
@@ -17265,15 +17451,24 @@ var Session = class {
17265
17451
  hasSide(snap, path, side) {
17266
17452
  return sidePath(this.readablePaths(snap), path, side) != null;
17267
17453
  }
17454
+ /** Whether `path` is a file of the review: a changed file (by its new path) or a tree path. A rename's old path is not. */
17455
+ hasFile(snap, path) {
17456
+ const r = this.readablePaths(snap);
17457
+ return r.tree.has(path) || r.changed.has(path);
17458
+ }
17268
17459
  /**
17269
- * Quotes a line range from the current snapshot, for imports that carry no
17270
- * `quoted`. Goes through `readSide`, so the allowlist applies.
17460
+ * Places imported anchors against the current snapshot: quotes a line range for
17461
+ * imports that carry no `quoted`, and says whether a file thread's file is in the
17462
+ * review. Goes through `readSide`, so the allowlist applies.
17271
17463
  */
17272
- quoter() {
17273
- return async (path, side, startLine, endLine) => {
17274
- const snap = await this.snapshotter.current();
17275
- const buf = await this.readSide(snap, path, side);
17276
- return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
17464
+ anchorSource() {
17465
+ return {
17466
+ quote: async (path, side, startLine, endLine) => {
17467
+ const snap = await this.snapshotter.current();
17468
+ const buf = await this.readSide(snap, path, side);
17469
+ return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
17470
+ },
17471
+ hasFile: async (path) => this.hasFile(await this.snapshotter.current(), path)
17277
17472
  };
17278
17473
  }
17279
17474
  readablePaths(snap) {
@@ -17325,7 +17520,7 @@ var Session = class {
17325
17520
  }
17326
17521
  };
17327
17522
  async function defaultWatcherFactory() {
17328
- const { Watcher } = await import("./Watcher-C_X03LRa.js");
17523
+ const { Watcher } = await import("./Watcher-CKwvGuPB.js");
17329
17524
  return (target) => new Watcher(target);
17330
17525
  }
17331
17526
  function readablePaths(snap) {
@@ -21013,14 +21208,16 @@ var WsHub = class {
21013
21208
  maxPayload: MAX_PAYLOAD_BYTES
21014
21209
  });
21015
21210
  clientListeners = /* @__PURE__ */ new Set();
21016
- attach(server, guard = () => true) {
21211
+ attach(server, guard = () => null) {
21017
21212
  server.on("upgrade", (req, socket, head) => {
21018
21213
  if (new URL(req.url ?? "/", "http://localhost").pathname !== "/ws") return;
21019
- if (!guard({
21214
+ const rejected = guard({
21020
21215
  host: req.headers.host,
21021
21216
  origin: req.headers.origin
21022
- })) {
21023
- socket.destroy();
21217
+ });
21218
+ if (rejected != null) {
21219
+ socket.once("finish", () => socket.destroy());
21220
+ socket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${rejected}\n`);
21024
21221
  return;
21025
21222
  }
21026
21223
  this.wss.handleUpgrade(req, socket, head, (ws) => {
@@ -21260,11 +21457,12 @@ Revisions follow git diff, except that a lone one compares from the merge base:
21260
21457
  diffle main...feat what feat added since it left main
21261
21458
  diffle main..worktree main vs the uncommitted tree ("worktree" works on either side)
21262
21459
 
21460
+ Without revisions, diffle compares HEAD..worktree.
21461
+
21263
21462
  Status goes to stderr, so stdout carries only the review: unless --keep-alive is set,
21264
21463
  closing the last auto-opened browser tab stops diffle and prints the open comments as a prompt for an agent.
21265
21464
  Ctrl+C always stops it.`).action(async (revs, _o, cmd) => {
21266
- if (revs.length === 0) cmd.help();
21267
- await run({
21465
+ await run(revs.length === 0 ? { kind: "working" } : {
21268
21466
  kind: "revspec",
21269
21467
  args: revs
21270
21468
  }, cmd.optsWithGlobals());