@moritzwilksch/diffle 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -131
- package/dist/client/assets/{angular-html-C3AR-WKY.js → angular-html-NJfvXMBu.js} +1 -1
- package/dist/client/assets/{angular-ts-bZzNmyh2.js → angular-ts-3VmQ16GC.js} +1 -1
- package/dist/client/assets/{apl-DKjQ1uUu.js → apl-CaYQRkUE.js} +1 -1
- package/dist/client/assets/{astro-D6YBrldj.js → astro-CGsy8jQJ.js} +1 -1
- package/dist/client/assets/{blade-QC3ty25r.js → blade-CbyPPxmo.js} +1 -1
- package/dist/client/assets/{c-CEfiNUIv.js → c-BaJwvHpK.js} +1 -1
- package/dist/client/assets/{chapel-XvVgmIw8.js → chapel-BokLUCVc.js} +1 -1
- package/dist/client/assets/{cobol-Cyvb7uZ8.js → cobol-I7_VFdfF.js} +1 -1
- package/dist/client/assets/{coffee-DKSUNEAJ.js → coffee-BJ7WLX7y.js} +1 -1
- package/dist/client/assets/{cpp-DvHlatt5.js → cpp-DSFvuOlH.js} +1 -1
- package/dist/client/assets/{crystal-BfxbaHfR.js → crystal-DLLJ7kug.js} +1 -1
- package/dist/client/assets/{css-CvzcDzFO.js → css-CW7SLGV7.js} +1 -1
- package/dist/client/assets/{edge-rOBhrNV9.js → edge-DQEyTxuu.js} +1 -1
- package/dist/client/assets/{elixir-D3B6PlMg.js → elixir-Dwvydtjv.js} +1 -1
- package/dist/client/assets/{elm-BnfJhLi3.js → elm-DPrZsI-D.js} +1 -1
- package/dist/client/assets/{erb-CkLdBLG4.js → erb-By39__m0.js} +1 -1
- package/dist/client/assets/{git-rebase-wqI2zI5G.js → git-rebase-CBF3buEb.js} +1 -1
- package/dist/client/assets/{glimmer-js-CorAzX48.js → glimmer-js-C4TvHlaw.js} +1 -1
- package/dist/client/assets/{glimmer-ts-DphgSML7.js → glimmer-ts-BPhq137Q.js} +1 -1
- package/dist/client/assets/{glsl-Mt2MgDCN.js → glsl-BlwDtIwk.js} +1 -1
- package/dist/client/assets/{graphql-CR58itij.js → graphql-BgMqs-o1.js} +1 -1
- package/dist/client/assets/{hack-BYLE2FB3.js → hack-DrMUHl8k.js} +1 -1
- package/dist/client/assets/{haml-Bo5vpKMC.js → haml-BWbuOop2.js} +1 -1
- package/dist/client/assets/{handlebars-Cd6w9Qte.js → handlebars-D-TP7YFr.js} +1 -1
- package/dist/client/assets/{html-EAc4fezP.js → html-RkKdupbJ.js} +1 -1
- package/dist/client/assets/{html-derivative-BCmkEMhH.js → html-derivative-BiXkRfVa.js} +1 -1
- package/dist/client/assets/{http-dQXVRwNo.js → http-CF05Tv0R.js} +1 -1
- package/dist/client/assets/{hurl-CTzL1YrB.js → hurl-p-K5zx-F.js} +1 -1
- package/dist/client/assets/index-C-uvIS11.css +2 -0
- package/dist/client/assets/{index-DWcJ2X-_.js → index-Dw_tHgT2.js} +118 -102
- package/dist/client/assets/{java-C6GmB9Ie.js → java-DZgPGAdX.js} +1 -1
- package/dist/client/assets/{javascript-C3Xp2ZJG.js → javascript-UpC6uRwd.js} +1 -1
- package/dist/client/assets/{jinja-Dm5uNF1A.js → jinja-BwlD6UdE.js} +1 -1
- package/dist/client/assets/{jison-DWN1PAPH.js → jison-CXzrtNYv.js} +1 -1
- package/dist/client/assets/{json-CD4Jl2TN.js → json-CvzeYtbd.js} +1 -1
- package/dist/client/assets/{jsx-Dk_mg_Sq.js → jsx-CRRNRy_v.js} +1 -1
- package/dist/client/assets/{julia-ChQLeZ-J.js → julia-n9zQTAkT.js} +1 -1
- package/dist/client/assets/{just-Dw1MNaQ7.js → just-Cqgx3k16.js} +1 -1
- package/dist/client/assets/{latex-CHuqntlZ.js → latex-CtWS0r44.js} +1 -1
- package/dist/client/assets/{liquid-CdqvKBd5.js → liquid-DYOfxp51.js} +1 -1
- package/dist/client/assets/{lua-DtCcLDnQ.js → lua-BH2jjxbT.js} +1 -1
- package/dist/client/assets/{marko-COZ6SLmm.js → marko-yGXqh8Mu.js} +1 -1
- package/dist/client/assets/{mdc-BTrsC_q_.js → mdc-DBTGzC9c.js} +1 -1
- package/dist/client/assets/{nginx-B_JY_bIl.js → nginx-C6OOwetR.js} +1 -1
- package/dist/client/assets/{nim-C7Boj9eE.js → nim-B-51OZwP.js} +1 -1
- package/dist/client/assets/{org-CYdh74lA.js → org-rdh6JnaZ.js} +1 -1
- package/dist/client/assets/{perl-CRiTh3Pi.js → perl-BEpLn7O_.js} +1 -1
- package/dist/client/assets/{php-DSko-lNI.js → php-uM1K2nIh.js} +1 -1
- package/dist/client/assets/{pug-BfUPln3e.js → pug-CX5jxSmg.js} +1 -1
- package/dist/client/assets/{qml-2jRMRran.js → qml-BjaL9kT9.js} +1 -1
- package/dist/client/assets/{r-sDW5nKEb.js → r-CbP_oADb.js} +1 -1
- package/dist/client/assets/{razor-Sa7GlYLb.js → razor-ClgwHmG8.js} +1 -1
- package/dist/client/assets/{regexp-AWVIUt-C.js → regexp-DkGv1hXs.js} +1 -1
- package/dist/client/assets/{rst-D20G5Wbg.js → rst-KbuceK-d.js} +1 -1
- package/dist/client/assets/{ruby-DevA-y7a.js → ruby-IyWK5TqJ.js} +1 -1
- package/dist/client/assets/{sas-DRiLtfNK.js → sas-CGY91YIG.js} +1 -1
- package/dist/client/assets/{scss-Ca4Dfxoe.js → scss-C5azgOdY.js} +1 -1
- package/dist/client/assets/{shellscript-BNnaYtXN.js → shellscript-Do9KSXv1.js} +1 -1
- package/dist/client/assets/{shellsession-B0nV-fpO.js → shellsession-DG2DOiiH.js} +1 -1
- package/dist/client/assets/{soy-CDZEZMSk.js → soy-BLO2pcf_.js} +1 -1
- package/dist/client/assets/{sql-s_6SuPDf.js → sql-NhQC2aCy.js} +1 -1
- package/dist/client/assets/{stata-CaVdvC6P.js → stata-BT8bwSiP.js} +1 -1
- package/dist/client/assets/{surrealql-DZ5TdMgT.js → surrealql-DeFUr0Sz.js} +1 -1
- package/dist/client/assets/{svelte-BkFQboE2.js → svelte-TRjcWU3h.js} +1 -1
- package/dist/client/assets/{templ-QUoUDGUH.js → templ-C1Ef7mnt.js} +1 -1
- package/dist/client/assets/{tex-89lMVAG3.js → tex-DWZKUBNC.js} +1 -1
- package/dist/client/assets/{ts-tags-B2u4oUPE.js → ts-tags-BPGHRwAa.js} +1 -1
- package/dist/client/assets/{tsx-ftFKhLnx.js → tsx-DFPjrnBq.js} +1 -1
- package/dist/client/assets/{twig-DQCCfpmq.js → twig-BnaDylmD.js} +1 -1
- package/dist/client/assets/{typescript-DhCWy8LC.js → typescript-CtjU5LIe.js} +1 -1
- package/dist/client/assets/{typst-N-Gv0lEU.js → typst-CKVv919t.js} +1 -1
- package/dist/client/assets/{vue-C7rUB8rE.js → vue-DQGhQ-pk.js} +1 -1
- package/dist/client/assets/{vue-html-D-dp6lDI.js → vue-html-BtU_VFsU.js} +1 -1
- package/dist/client/assets/{vue-vine-DEpfajiz.js → vue-vine-Bg_4C7Q_.js} +1 -1
- package/dist/client/assets/{xml-BGDSWH1X.js → xml-DupdE_s3.js} +1 -1
- package/dist/client/assets/{xsl-D8Xmp-zV.js → xsl-BkEIn0qW.js} +1 -1
- package/dist/client/assets/{yaml-B3GKVR80.js → yaml-BuH0PmqQ.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/main.js +987 -68
- package/package.json +5 -3
- package/dist/client/assets/index-BkCL4UDj.css +0 -2
package/dist/server/main.js
CHANGED
|
@@ -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.
|
|
3955
|
+
var version$1 = "0.2.0";
|
|
3955
3956
|
//#endregion
|
|
3956
3957
|
//#region src/server/comments/anchor.ts
|
|
3957
3958
|
function splitLines(contents) {
|
|
@@ -4108,6 +4109,12 @@ function patchArgs(oldRev) {
|
|
|
4108
4109
|
}
|
|
4109
4110
|
/** An idle `cat-file --batch` is reaped after this long; the next request respawns one. */
|
|
4110
4111
|
const CAT_FILE_IDLE_MS = 2e3;
|
|
4112
|
+
/**
|
|
4113
|
+
* `git range-diff` pairs two commits while the diff between their patches costs less than this
|
|
4114
|
+
* percentage of a patch's size. Git's default of 60 leaves a reworded three-line commit unpaired,
|
|
4115
|
+
* since the message change alone outweighs it; at 100 the message may change as much as the patch.
|
|
4116
|
+
*/
|
|
4117
|
+
const CREATION_FACTOR = 100;
|
|
4111
4118
|
var GitError = class extends Error {
|
|
4112
4119
|
args;
|
|
4113
4120
|
code;
|
|
@@ -4184,6 +4191,16 @@ var GitRepo = class GitRepo {
|
|
|
4184
4191
|
`${rev}^{commit}`
|
|
4185
4192
|
])).trim();
|
|
4186
4193
|
}
|
|
4194
|
+
/** The tree a commit points at. */
|
|
4195
|
+
async tree(rev) {
|
|
4196
|
+
return (await this.text([
|
|
4197
|
+
"rev-parse",
|
|
4198
|
+
"--verify",
|
|
4199
|
+
"--quiet",
|
|
4200
|
+
"--end-of-options",
|
|
4201
|
+
`${rev}^{tree}`
|
|
4202
|
+
])).trim();
|
|
4203
|
+
}
|
|
4187
4204
|
/** Commit messages for two ancestor offsets, pinned to one HEAD. */
|
|
4188
4205
|
async lastCommitsPreview(oldOffset, newOffset) {
|
|
4189
4206
|
const head = await this.commitInfo("HEAD");
|
|
@@ -4198,6 +4215,7 @@ var GitRepo = class GitRepo {
|
|
|
4198
4215
|
new: next
|
|
4199
4216
|
};
|
|
4200
4217
|
}
|
|
4218
|
+
/** A commit's hash, abbreviation and full message; null when `rev` names no commit. */
|
|
4201
4219
|
async commitInfo(rev) {
|
|
4202
4220
|
let sha;
|
|
4203
4221
|
try {
|
|
@@ -4221,6 +4239,66 @@ var GitRepo = class GitRepo {
|
|
|
4221
4239
|
message: output.slice(separator + 1).trimEnd()
|
|
4222
4240
|
};
|
|
4223
4241
|
}
|
|
4242
|
+
/**
|
|
4243
|
+
* `sha`'s first parent, and the commit toward HEAD whose first parent it is. The
|
|
4244
|
+
* child is null at HEAD, on an unborn branch, or when `sha` is off HEAD's
|
|
4245
|
+
* first-parent line, e.g. on a merged side branch.
|
|
4246
|
+
*/
|
|
4247
|
+
async commitNeighbours(sha) {
|
|
4248
|
+
const [parent, list] = await Promise.all([this.resolve(`${sha}^`).catch((e) => {
|
|
4249
|
+
if (e instanceof GitError && e.code === 1) return null;
|
|
4250
|
+
throw e;
|
|
4251
|
+
}), this.text([
|
|
4252
|
+
"rev-list",
|
|
4253
|
+
"--first-parent",
|
|
4254
|
+
"--ancestry-path",
|
|
4255
|
+
"--parents",
|
|
4256
|
+
`${sha}..HEAD`,
|
|
4257
|
+
"--"
|
|
4258
|
+
]).catch(() => "")]);
|
|
4259
|
+
const [child, firstParent] = list.trim().split("\n").pop()?.split(" ") ?? [];
|
|
4260
|
+
return {
|
|
4261
|
+
parent,
|
|
4262
|
+
child: child && firstParent === sha ? child : null
|
|
4263
|
+
};
|
|
4264
|
+
}
|
|
4265
|
+
/** The commits a revision range such as `a..b` or `c^!` selects, oldest first: the newest `limit`, and the total. */
|
|
4266
|
+
async rangeCommits(range, limit) {
|
|
4267
|
+
const [count, log] = await Promise.all([this.text([
|
|
4268
|
+
"rev-list",
|
|
4269
|
+
"--count",
|
|
4270
|
+
"--end-of-options",
|
|
4271
|
+
range,
|
|
4272
|
+
"--"
|
|
4273
|
+
]), this.text([
|
|
4274
|
+
"log",
|
|
4275
|
+
"-z",
|
|
4276
|
+
"--no-show-signature",
|
|
4277
|
+
"--reverse",
|
|
4278
|
+
`--max-count=${limit}`,
|
|
4279
|
+
"--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%B",
|
|
4280
|
+
"--end-of-options",
|
|
4281
|
+
range,
|
|
4282
|
+
"--"
|
|
4283
|
+
])]);
|
|
4284
|
+
const fields = log.split("\0");
|
|
4285
|
+
const list = [];
|
|
4286
|
+
for (let i = 0; i + 5 < fields.length; i += 6) {
|
|
4287
|
+
const [sha = "", short = "", author = "", email = "", time = "", message = ""] = fields.slice(i, i + 6);
|
|
4288
|
+
list.push({
|
|
4289
|
+
sha,
|
|
4290
|
+
short,
|
|
4291
|
+
message: message.trimEnd(),
|
|
4292
|
+
author,
|
|
4293
|
+
email,
|
|
4294
|
+
date: Number(time) * 1e3
|
|
4295
|
+
});
|
|
4296
|
+
}
|
|
4297
|
+
return {
|
|
4298
|
+
list,
|
|
4299
|
+
total: Number(count.trim())
|
|
4300
|
+
};
|
|
4301
|
+
}
|
|
4224
4302
|
/** The empty tree under the repository's hash algorithm: the old side of an unborn branch. */
|
|
4225
4303
|
async emptyTree() {
|
|
4226
4304
|
return (await this.text([
|
|
@@ -4238,6 +4316,89 @@ var GitRepo = class GitRepo {
|
|
|
4238
4316
|
b
|
|
4239
4317
|
])).trim();
|
|
4240
4318
|
}
|
|
4319
|
+
/**
|
|
4320
|
+
* Replays what `head` changed since `base` onto `onto`, in memory: the resulting tree, and the
|
|
4321
|
+
* paths the merge could not settle, which the tree holds with conflict markers. `--write-tree`
|
|
4322
|
+
* needs git 2.38. With `-z`, the tree, each conflicted path and the sections end in NUL.
|
|
4323
|
+
*/
|
|
4324
|
+
async replay(base, onto, head) {
|
|
4325
|
+
const [tree = "", ...rest] = (await this.text([
|
|
4326
|
+
"merge-tree",
|
|
4327
|
+
"--write-tree",
|
|
4328
|
+
"-z",
|
|
4329
|
+
"--name-only",
|
|
4330
|
+
`--merge-base=${base}`,
|
|
4331
|
+
"--end-of-options",
|
|
4332
|
+
onto,
|
|
4333
|
+
head
|
|
4334
|
+
], { okCodes: [1] })).split("\0");
|
|
4335
|
+
const conflicts = [];
|
|
4336
|
+
for (const path of rest) {
|
|
4337
|
+
if (path === "") break;
|
|
4338
|
+
conflicts.push(path);
|
|
4339
|
+
}
|
|
4340
|
+
return {
|
|
4341
|
+
tree,
|
|
4342
|
+
conflicts
|
|
4343
|
+
};
|
|
4344
|
+
}
|
|
4345
|
+
/**
|
|
4346
|
+
* How `git range-diff` pairs the commits of two ranges, in its output order: abbreviated
|
|
4347
|
+
* hashes, null for the side a commit has no counterpart on, and its marker (`=` identical,
|
|
4348
|
+
* `!` changed, `<` only in the first range, `>` only in the second). Summary only, no patches.
|
|
4349
|
+
* Ranges are given as hashes: range-diff learnt `--end-of-options` only in git 2.44.
|
|
4350
|
+
*/
|
|
4351
|
+
async rangeDiff(oldRange, newRange) {
|
|
4352
|
+
const out = await this.text([
|
|
4353
|
+
"range-diff",
|
|
4354
|
+
"-s",
|
|
4355
|
+
"--no-color",
|
|
4356
|
+
`--creation-factor=${CREATION_FACTOR}`,
|
|
4357
|
+
oldRange,
|
|
4358
|
+
newRange
|
|
4359
|
+
]);
|
|
4360
|
+
const pairs = [];
|
|
4361
|
+
for (const line of out.split("\n")) {
|
|
4362
|
+
const m = /^\s*(?:\d+:\s+([0-9a-f]+)|-:\s+-+)\s+([=!<>])\s+(?:\d+:\s+([0-9a-f]+)|-:\s+-+)\s/.exec(line);
|
|
4363
|
+
if (m) pairs.push({
|
|
4364
|
+
old: m[1] ?? null,
|
|
4365
|
+
new: m[3] ?? null,
|
|
4366
|
+
marker: m[2]
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
return pairs;
|
|
4370
|
+
}
|
|
4371
|
+
/** Each commit's stable patch id, so two commits with the same change under different messages compare equal. */
|
|
4372
|
+
async patchIds(shas) {
|
|
4373
|
+
const ids = /* @__PURE__ */ new Map();
|
|
4374
|
+
if (!shas.length) return ids;
|
|
4375
|
+
const patches = await this.exec([
|
|
4376
|
+
"log",
|
|
4377
|
+
"-p",
|
|
4378
|
+
"--no-walk",
|
|
4379
|
+
"--no-show-signature",
|
|
4380
|
+
"--format=%H",
|
|
4381
|
+
"--end-of-options",
|
|
4382
|
+
...shas
|
|
4383
|
+
]);
|
|
4384
|
+
for (const line of (await this.text(["patch-id", "--stable"], { input: patches })).split("\n")) {
|
|
4385
|
+
const [id, sha] = line.split(" ");
|
|
4386
|
+
if (id && sha) ids.set(sha, id);
|
|
4387
|
+
}
|
|
4388
|
+
return ids;
|
|
4389
|
+
}
|
|
4390
|
+
/**
|
|
4391
|
+
* Points refs at objects so they survive `gc` and a force-push, or with null deletes them. Refs
|
|
4392
|
+
* must stay inside `refs/diffle/iterations/`: nothing the user owns moves.
|
|
4393
|
+
*/
|
|
4394
|
+
async pin(refs) {
|
|
4395
|
+
const lines = [];
|
|
4396
|
+
for (const [ref, sha] of Object.entries(refs)) {
|
|
4397
|
+
if (!ref.startsWith("refs/diffle/iterations/")) throw new GitError(`refusing to update ${ref}`, ["update-ref"], null, "");
|
|
4398
|
+
lines.push(sha === null ? `delete ${ref}\n` : `update ${ref} ${sha}\n`);
|
|
4399
|
+
}
|
|
4400
|
+
if (lines.length) await this.exec(["update-ref", "--stdin"], { input: lines.join("") });
|
|
4401
|
+
}
|
|
4241
4402
|
/** origin/HEAD → main → master. */
|
|
4242
4403
|
async defaultBranch() {
|
|
4243
4404
|
try {
|
|
@@ -11828,6 +11989,20 @@ const ZodRecord = /*@__PURE__*/ $constructor("ZodRecord", (inst, def) => {
|
|
|
11828
11989
|
inst.keyType = def.keyType;
|
|
11829
11990
|
inst.valueType = def.valueType;
|
|
11830
11991
|
});
|
|
11992
|
+
function record(keyType, valueType, params) {
|
|
11993
|
+
if (!valueType || !valueType._zod) return new ZodRecord({
|
|
11994
|
+
type: "record",
|
|
11995
|
+
keyType: string(),
|
|
11996
|
+
valueType: keyType,
|
|
11997
|
+
...normalizeParams(valueType)
|
|
11998
|
+
});
|
|
11999
|
+
return new ZodRecord({
|
|
12000
|
+
type: "record",
|
|
12001
|
+
keyType,
|
|
12002
|
+
valueType,
|
|
12003
|
+
...normalizeParams(params)
|
|
12004
|
+
});
|
|
12005
|
+
}
|
|
11831
12006
|
function partialRecord(keyType, valueType, params) {
|
|
11832
12007
|
return new ZodRecord({
|
|
11833
12008
|
type: "record",
|
|
@@ -12099,14 +12274,48 @@ const ModeRequestSchema = discriminatedUnion("kind", [
|
|
|
12099
12274
|
object({
|
|
12100
12275
|
kind: literal("revspec"),
|
|
12101
12276
|
args: string().array()
|
|
12277
|
+
}),
|
|
12278
|
+
object({
|
|
12279
|
+
kind: literal("focus"),
|
|
12280
|
+
commit: string().nullable()
|
|
12281
|
+
}),
|
|
12282
|
+
object({
|
|
12283
|
+
kind: literal("interdiff"),
|
|
12284
|
+
from: number().int().positive(),
|
|
12285
|
+
to: number().int().positive()
|
|
12286
|
+
}),
|
|
12287
|
+
object({
|
|
12288
|
+
kind: literal("pair"),
|
|
12289
|
+
commit: string().nullable()
|
|
12102
12290
|
})
|
|
12103
12291
|
]);
|
|
12104
|
-
/**
|
|
12105
|
-
|
|
12292
|
+
/**
|
|
12293
|
+
* One state of a reviewed range: its resolved endpoints when a snapshot of it was taken. A new
|
|
12294
|
+
* one is recorded whenever the endpoints differ from the last, so a push, rebase or amend
|
|
12295
|
+
* yields the next iteration; the commits are pinned under `refs/diffle/iterations/`.
|
|
12296
|
+
*/
|
|
12297
|
+
const IterationSchema = object({
|
|
12298
|
+
/** 1-based, in recording order. */
|
|
12299
|
+
n: number(),
|
|
12300
|
+
oldSha: string(),
|
|
12301
|
+
newSha: string(),
|
|
12302
|
+
/** Epoch milliseconds. */
|
|
12303
|
+
recordedAt: number()
|
|
12304
|
+
});
|
|
12305
|
+
/**
|
|
12306
|
+
* How the old side is derived: `old` itself, merge-base(old, new) with worktree
|
|
12307
|
+
* taken as HEAD, or new's first parent (the empty tree for a root commit).
|
|
12308
|
+
*/
|
|
12309
|
+
const ComparisonBaseSchema = _enum([
|
|
12310
|
+
"direct",
|
|
12311
|
+
"merge-base",
|
|
12312
|
+
"parent"
|
|
12313
|
+
]);
|
|
12314
|
+
/** A comparison; both endpoints accept a Git revision or "worktree". Under `parent`, both name the one commit. */
|
|
12315
|
+
const ComparisonSchema = object({
|
|
12106
12316
|
old: string(),
|
|
12107
12317
|
new: string(),
|
|
12108
|
-
|
|
12109
|
-
mergeBase: boolean(),
|
|
12318
|
+
base: ComparisonBaseSchema,
|
|
12110
12319
|
live: _enum([
|
|
12111
12320
|
"worktree",
|
|
12112
12321
|
"refs",
|
|
@@ -12115,10 +12324,88 @@ const ModeSpecSchema = object({
|
|
|
12115
12324
|
/** Fixed when the comparison is entered; discovery never changes comment storage. */
|
|
12116
12325
|
commentKey: string()
|
|
12117
12326
|
});
|
|
12118
|
-
|
|
12327
|
+
const CommitInfoSchema = object({
|
|
12328
|
+
sha: string(),
|
|
12329
|
+
short: string(),
|
|
12330
|
+
message: string()
|
|
12331
|
+
});
|
|
12332
|
+
/** A single reviewed commit and its neighbours along HEAD's first-parent history, for stepping through it. */
|
|
12333
|
+
const ReviewedCommitSchema = CommitInfoSchema.extend({
|
|
12334
|
+
/** First parent; null for a root commit. */
|
|
12335
|
+
parent: string().nullable(),
|
|
12336
|
+
/** The commit whose first parent this is, toward HEAD; null at HEAD or off HEAD's first-parent line. */
|
|
12337
|
+
child: string().nullable()
|
|
12338
|
+
});
|
|
12339
|
+
/** One commit of the compared range. */
|
|
12340
|
+
const RangeCommitSchema = CommitInfoSchema.extend({
|
|
12341
|
+
author: string(),
|
|
12342
|
+
email: string(),
|
|
12343
|
+
/** Author date, epoch milliseconds. */
|
|
12344
|
+
date: number()
|
|
12345
|
+
});
|
|
12346
|
+
/** How a commit of the older iteration relates to one of the newer, as `git range-diff` pairs them. */
|
|
12347
|
+
const PairStatusSchema = _enum([
|
|
12348
|
+
"identical",
|
|
12349
|
+
"changed",
|
|
12350
|
+
"message",
|
|
12351
|
+
"added",
|
|
12352
|
+
"dropped"
|
|
12353
|
+
]);
|
|
12354
|
+
/**
|
|
12355
|
+
* One row of a range-diff: `identical` and `changed` pairs have both commits, a `message` pair
|
|
12356
|
+
* the same patch under a new message, an `added` commit no old side and a `dropped` one no new side.
|
|
12357
|
+
*/
|
|
12358
|
+
const RangePairSchema = object({
|
|
12359
|
+
old: RangeCommitSchema.nullable(),
|
|
12360
|
+
new: RangeCommitSchema.nullable(),
|
|
12361
|
+
status: PairStatusSchema
|
|
12362
|
+
});
|
|
12363
|
+
/**
|
|
12364
|
+
* Two iterations of a range compared. The old side is `from`'s head replayed onto `to`'s base
|
|
12365
|
+
* (a tree, not a commit), so a rebase in between does not show up as the upstream's changes;
|
|
12366
|
+
* the new side is `to`'s head.
|
|
12367
|
+
*/
|
|
12368
|
+
const InterdiffSchema = object({
|
|
12369
|
+
from: IterationSchema,
|
|
12370
|
+
to: IterationSchema,
|
|
12371
|
+
/** Paths the replay could not merge; they carry conflict markers on the old side. */
|
|
12372
|
+
conflicts: string().array(),
|
|
12373
|
+
/** The two ranges' commits paired, in `git range-diff` order: the newer range's, dropped commits where they were. */
|
|
12374
|
+
pairs: RangePairSchema.array()
|
|
12375
|
+
});
|
|
12376
|
+
/**
|
|
12377
|
+
* One pair of an interdiff shown alone: `new` against `old` replayed onto `new`'s parent, so the
|
|
12378
|
+
* pair's own amendments show without the rebase; an added commit (`old` null) against its parent.
|
|
12379
|
+
*/
|
|
12380
|
+
const PairSpecSchema = object({
|
|
12381
|
+
old: string().nullable(),
|
|
12382
|
+
new: string(),
|
|
12383
|
+
conflicts: string().array()
|
|
12384
|
+
});
|
|
12385
|
+
const ModeSpecSchema = ComparisonSchema.extend({
|
|
12386
|
+
/** Set while one commit of a range is focused, or two of its iterations compared: the range, whose commits the snapshot keeps listing. */
|
|
12387
|
+
within: ComparisonSchema.optional(),
|
|
12388
|
+
interdiff: InterdiffSchema.optional(),
|
|
12389
|
+
/** Set with `interdiff` while one of its pairs is shown. */
|
|
12390
|
+
pair: PairSpecSchema.optional()
|
|
12391
|
+
});
|
|
12392
|
+
/**
|
|
12393
|
+
* Display comparison endpoints with branch names instead of internal ref namespaces; a single commit as
|
|
12394
|
+
* `<sha>^!`, as `<range> @ <sha>` when focused within a range, as `<range> #1→#2` for an interdiff and
|
|
12395
|
+
* `<range> #1→#2 @ <sha>` for one of its pairs.
|
|
12396
|
+
*/
|
|
12119
12397
|
function comparisonLabel(mode) {
|
|
12120
|
-
|
|
12121
|
-
|
|
12398
|
+
if (mode.within && mode.interdiff) {
|
|
12399
|
+
const label = `${comparisonLabel(mode.within)} #${mode.interdiff.from.n}→#${mode.interdiff.to.n}`;
|
|
12400
|
+
return mode.pair ? `${label} @ ${mode.pair.new.slice(0, 7)}` : label;
|
|
12401
|
+
}
|
|
12402
|
+
if (mode.within) return `${comparisonLabel(mode.within)} @ ${mode.new.slice(0, 7)}`;
|
|
12403
|
+
if (mode.base === "parent") return `${mode.new.slice(0, 7)}^!`;
|
|
12404
|
+
return `${refName(mode.old)}${mode.base === "merge-base" ? "..." : ".."}${refName(mode.new)}`;
|
|
12405
|
+
}
|
|
12406
|
+
/** A comparison endpoint as the user named it: without diffle's PR namespace or `refs/heads/`. */
|
|
12407
|
+
function refName(rev) {
|
|
12408
|
+
return rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
|
|
12122
12409
|
}
|
|
12123
12410
|
const GithubPullRequestSchema = object({
|
|
12124
12411
|
repository: string(),
|
|
@@ -12168,6 +12455,18 @@ const ChangedFileSchema = object({
|
|
|
12168
12455
|
/** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
|
|
12169
12456
|
submodule: literal(true).optional()
|
|
12170
12457
|
});
|
|
12458
|
+
/**
|
|
12459
|
+
* The commits a comparison spans: old..new, with the worktree taken as HEAD, or the one commit under `parent`.
|
|
12460
|
+
* A focused commit lists its range's.
|
|
12461
|
+
*/
|
|
12462
|
+
const RangeCommitsSchema = object({
|
|
12463
|
+
/** Oldest first; the newest MAX_RANGE_COMMITS when there are more. */
|
|
12464
|
+
list: RangeCommitSchema.array(),
|
|
12465
|
+
total: number(),
|
|
12466
|
+
/** The spanned comparison's resolved endpoints: the snapshot's own, or the range's when a commit is focused. */
|
|
12467
|
+
oldSha: string(),
|
|
12468
|
+
newSha: string()
|
|
12469
|
+
});
|
|
12171
12470
|
object({
|
|
12172
12471
|
root: string(),
|
|
12173
12472
|
mode: ModeSpecSchema,
|
|
@@ -12182,7 +12481,23 @@ object({
|
|
|
12182
12481
|
context: number(),
|
|
12183
12482
|
changed: ChangedFileSchema.array(),
|
|
12184
12483
|
/** All paths on the new side: the new commit's tree, or index ∪ untracked for the worktree. Sorted. */
|
|
12185
|
-
tree: string().array()
|
|
12484
|
+
tree: string().array(),
|
|
12485
|
+
/** The reviewed commit when the comparison is a single commit against its parent; otherwise null. */
|
|
12486
|
+
commit: ReviewedCommitSchema.nullable(),
|
|
12487
|
+
commits: RangeCommitsSchema,
|
|
12488
|
+
/** The recorded iterations of the range `commits` spans, oldest first; empty for comparisons that are not followed. */
|
|
12489
|
+
iterations: IterationSchema.array()
|
|
12490
|
+
});
|
|
12491
|
+
/**
|
|
12492
|
+
* The compared refs resolve elsewhere than when the shown snapshot was taken. A refs-live
|
|
12493
|
+
* comparison is not recomputed under the reviewer; it reloads on request.
|
|
12494
|
+
*/
|
|
12495
|
+
const MovedSchema = object({
|
|
12496
|
+
/** The snapshot a reload would supersede. */
|
|
12497
|
+
version: number(),
|
|
12498
|
+
/** Where the comparison `Snapshot.commits` spans resolves now. */
|
|
12499
|
+
oldSha: string(),
|
|
12500
|
+
newSha: string()
|
|
12186
12501
|
});
|
|
12187
12502
|
/** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
|
|
12188
12503
|
const PatchRequestSchema = object({ paths: string().array().max(200) });
|
|
@@ -12279,11 +12594,6 @@ const ViewedEntrySchema = object({
|
|
|
12279
12594
|
blob: string(),
|
|
12280
12595
|
viewed: boolean()
|
|
12281
12596
|
});
|
|
12282
|
-
const CommitInfoSchema = object({
|
|
12283
|
-
sha: string(),
|
|
12284
|
-
short: string(),
|
|
12285
|
-
message: string()
|
|
12286
|
-
});
|
|
12287
12597
|
object({
|
|
12288
12598
|
old: CommitInfoSchema.nullable(),
|
|
12289
12599
|
new: CommitInfoSchema.nullable()
|
|
@@ -12319,11 +12629,24 @@ object({
|
|
|
12319
12629
|
matches: SearchMatchSchema.array(),
|
|
12320
12630
|
truncated: boolean()
|
|
12321
12631
|
});
|
|
12632
|
+
/**
|
|
12633
|
+
* How a review between refs reacts when one of them moves. `on`: recompute at once, as a worktree
|
|
12634
|
+
* review follows edits. `off`: announce the move and wait for Reload. `auto`: wait for Reload when an
|
|
12635
|
+
* endpoint names a branch, whose pushes and rebases should not swap the review out under the
|
|
12636
|
+
* reviewer; recompute at once otherwise (a detached HEAD, a tag, an expression).
|
|
12637
|
+
*/
|
|
12638
|
+
const FollowRefsSchema = _enum([
|
|
12639
|
+
"on",
|
|
12640
|
+
"auto",
|
|
12641
|
+
"off"
|
|
12642
|
+
]);
|
|
12322
12643
|
object({
|
|
12323
12644
|
/** Globs (picomatch syntax) for files that start viewed + collapsed. Empty by default. */
|
|
12324
12645
|
autoViewed: string().array(),
|
|
12325
12646
|
/** Unchanged lines shown around each change (git -U). Default 5. */
|
|
12326
12647
|
contextLines: number(),
|
|
12648
|
+
/** When a review between refs recomputes as its refs move; see `FollowRefsSchema`. Default auto. */
|
|
12649
|
+
followRefs: FollowRefsSchema,
|
|
12327
12650
|
/**
|
|
12328
12651
|
* Language server command per language, replacing the built-in candidate for it; an empty
|
|
12329
12652
|
* string disables the language. Read-only over HTTP; set with `diffle config set-lsp`.
|
|
@@ -12333,6 +12656,7 @@ object({
|
|
|
12333
12656
|
const DEFAULT_USER_CONFIG = {
|
|
12334
12657
|
autoViewed: [],
|
|
12335
12658
|
contextLines: 5,
|
|
12659
|
+
followRefs: "auto",
|
|
12336
12660
|
lspCommands: {}
|
|
12337
12661
|
};
|
|
12338
12662
|
/** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */
|
|
@@ -12403,6 +12727,18 @@ object({
|
|
|
12403
12727
|
object({
|
|
12404
12728
|
/** Null when the position is not inside a token or the server does not classify tokens. */
|
|
12405
12729
|
kind: string().nullable() });
|
|
12730
|
+
object({ occurrences: object({
|
|
12731
|
+
line: number().int().positive(),
|
|
12732
|
+
col: number().int().nonnegative(),
|
|
12733
|
+
endLine: number().int().positive(),
|
|
12734
|
+
endCol: number().int().nonnegative(),
|
|
12735
|
+
/** LSP DocumentHighlightKind: a plain textual match, a read, or a write. */
|
|
12736
|
+
kind: _enum([
|
|
12737
|
+
"text",
|
|
12738
|
+
"read",
|
|
12739
|
+
"write"
|
|
12740
|
+
])
|
|
12741
|
+
}).array() });
|
|
12406
12742
|
object({
|
|
12407
12743
|
name: string(),
|
|
12408
12744
|
/** LSP SymbolKind number; the client maps it to a label. */
|
|
@@ -12425,6 +12761,10 @@ discriminatedUnion("type", [
|
|
|
12425
12761
|
type: literal("snapshot"),
|
|
12426
12762
|
version: number()
|
|
12427
12763
|
}),
|
|
12764
|
+
object({
|
|
12765
|
+
type: literal("moved"),
|
|
12766
|
+
moved: MovedSchema.nullable()
|
|
12767
|
+
}),
|
|
12428
12768
|
object({ type: literal("threads") }),
|
|
12429
12769
|
object({ type: literal("viewed") }),
|
|
12430
12770
|
object({ type: literal("config") }),
|
|
@@ -12518,7 +12858,8 @@ function noServer(lsp, language) {
|
|
|
12518
12858
|
/** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
|
|
12519
12859
|
const ConfigUpdateSchema = object({
|
|
12520
12860
|
autoViewed: string().array().optional(),
|
|
12521
|
-
contextLines: number().int().min(0).max(1e4).optional()
|
|
12861
|
+
contextLines: number().int().min(0).max(1e4).optional(),
|
|
12862
|
+
followRefs: FollowRefsSchema.optional()
|
|
12522
12863
|
});
|
|
12523
12864
|
const ResolvedRequestSchema = object({ resolved: boolean() });
|
|
12524
12865
|
const ViewedBulkRequestSchema = object({ entries: ViewedEntrySchema.array() });
|
|
@@ -12564,6 +12905,76 @@ const ClearThreadsQuerySchema = object({ stale: literal("1").optional() });
|
|
|
12564
12905
|
const PatchQuerySchema = object({ path: string().optional() });
|
|
12565
12906
|
object({ error: string() });
|
|
12566
12907
|
//#endregion
|
|
12908
|
+
//#region src/shared/fuzzy.ts
|
|
12909
|
+
const MATCH = 1;
|
|
12910
|
+
const WORD_START = 8;
|
|
12911
|
+
const CONSECUTIVE = 4;
|
|
12912
|
+
const GAP = 1;
|
|
12913
|
+
/**
|
|
12914
|
+
* Score `candidate` against `query`, matched case-insensitively as an ordered subsequence;
|
|
12915
|
+
* null when it does not match. Higher is better: matches at word starts (after `_-./`, camelCase
|
|
12916
|
+
* humps, digit runs) and unbroken runs count most, so `AbsBuiFa` finds `AbstractBuilderFactory`
|
|
12917
|
+
* and `evruca` finds `evaluate_run_cases.py`.
|
|
12918
|
+
*/
|
|
12919
|
+
function fuzzyScore(query, candidate) {
|
|
12920
|
+
const q = Array.from(query.toLowerCase());
|
|
12921
|
+
const chars = Array.from(candidate);
|
|
12922
|
+
const n = q.length;
|
|
12923
|
+
const m = chars.length;
|
|
12924
|
+
if (n === 0) return 0;
|
|
12925
|
+
if (n > m) return null;
|
|
12926
|
+
const lower = chars.map((ch) => ch.toLowerCase());
|
|
12927
|
+
const bonus = chars.map((ch, j) => MATCH + wordStart(chars[j - 1], ch));
|
|
12928
|
+
let prev = new Float64Array(m).fill(-Infinity);
|
|
12929
|
+
for (let i = 0; i < n; i++) {
|
|
12930
|
+
const cur = new Float64Array(m).fill(-Infinity);
|
|
12931
|
+
let carry = -Infinity;
|
|
12932
|
+
for (let j = i; j < m; j++) {
|
|
12933
|
+
if (i > 0) carry = Math.max(carry - GAP, prev[j - 1]);
|
|
12934
|
+
if (lower[j] !== q[i]) continue;
|
|
12935
|
+
cur[j] = (i === 0 ? 0 : Math.max(carry, prev[j - 1] + CONSECUTIVE)) + bonus[j];
|
|
12936
|
+
}
|
|
12937
|
+
prev = cur;
|
|
12938
|
+
}
|
|
12939
|
+
const best = Math.max(...prev);
|
|
12940
|
+
return best === -Infinity ? null : best;
|
|
12941
|
+
}
|
|
12942
|
+
function wordStart(before, ch) {
|
|
12943
|
+
if (before === void 0) return 10;
|
|
12944
|
+
if (!ALNUM.test(before)) return WORD_START;
|
|
12945
|
+
if (UPPER.test(ch) && !UPPER.test(before)) return WORD_START;
|
|
12946
|
+
if (DIGIT.test(ch) && !DIGIT.test(before)) return WORD_START;
|
|
12947
|
+
return 0;
|
|
12948
|
+
}
|
|
12949
|
+
const ALNUM = /[\p{L}\p{N}]/u;
|
|
12950
|
+
const UPPER = /\p{Lu}/u;
|
|
12951
|
+
const DIGIT = /\p{N}/u;
|
|
12952
|
+
/**
|
|
12953
|
+
* `items` whose `key` matches `query`, best first; ties prefer the shorter key, then input order.
|
|
12954
|
+
* A blank query keeps every item in order.
|
|
12955
|
+
*/
|
|
12956
|
+
function fuzzyFilter(items, query, key) {
|
|
12957
|
+
const q = query.trim();
|
|
12958
|
+
if (!q) return [...items];
|
|
12959
|
+
const scored = [];
|
|
12960
|
+
for (const item of items) {
|
|
12961
|
+
const k = key(item);
|
|
12962
|
+
const score = fuzzyScore(q, k);
|
|
12963
|
+
if (score !== null) scored.push({
|
|
12964
|
+
item,
|
|
12965
|
+
score,
|
|
12966
|
+
length: k.length
|
|
12967
|
+
});
|
|
12968
|
+
}
|
|
12969
|
+
return scored.sort((a, b) => b.score - a.score || a.length - b.length).map((s) => s.item);
|
|
12970
|
+
}
|
|
12971
|
+
/** `fuzzyFilter`'s order, followed by the items it rejects in input order: re-ranks hits another matcher chose. */
|
|
12972
|
+
function fuzzyRank(items, query, key) {
|
|
12973
|
+
const matched = fuzzyFilter(items, query, key);
|
|
12974
|
+
const kept = new Set(matched);
|
|
12975
|
+
return [...matched, ...items.filter((item) => !kept.has(item))];
|
|
12976
|
+
}
|
|
12977
|
+
//#endregion
|
|
12567
12978
|
//#region src/server/lsp/hover.ts
|
|
12568
12979
|
/**
|
|
12569
12980
|
* One markdown document for the tooltip, or null when the server sent nothing
|
|
@@ -13133,6 +13544,30 @@ var LspBridge = class LspBridge {
|
|
|
13133
13544
|
}
|
|
13134
13545
|
return { kind: null };
|
|
13135
13546
|
}
|
|
13547
|
+
/**
|
|
13548
|
+
* Where else the symbol at `pos` occurs in its file, via `textDocument/documentHighlight`,
|
|
13549
|
+
* so scope and shadowing are the server's call. A server without the method, or a
|
|
13550
|
+
* position holding no symbol, yields no occurrences: the answer is advisory.
|
|
13551
|
+
*/
|
|
13552
|
+
async occurrences(pos) {
|
|
13553
|
+
let result;
|
|
13554
|
+
try {
|
|
13555
|
+
result = await this.query("textDocument/documentHighlight", {
|
|
13556
|
+
textDocument: { uri: this.uri(pos.path) },
|
|
13557
|
+
position: toLsp(pos)
|
|
13558
|
+
}, pos.path);
|
|
13559
|
+
} catch (e) {
|
|
13560
|
+
if (e instanceof LspUnavailableError) throw e;
|
|
13561
|
+
return { occurrences: [] };
|
|
13562
|
+
}
|
|
13563
|
+
return { occurrences: (result ?? []).map(({ range: { start, end }, kind }) => ({
|
|
13564
|
+
line: start.line + 1,
|
|
13565
|
+
col: start.character,
|
|
13566
|
+
endLine: end.line + 1,
|
|
13567
|
+
endCol: end.character,
|
|
13568
|
+
kind: kind === 3 ? "write" : kind === 2 ? "read" : "text"
|
|
13569
|
+
})) };
|
|
13570
|
+
}
|
|
13136
13571
|
async references(pos, opts = {}) {
|
|
13137
13572
|
const result = await this.query("textDocument/references", {
|
|
13138
13573
|
textDocument: { uri: this.uri(pos.path) },
|
|
@@ -13215,7 +13650,7 @@ var LspBridge = class LspBridge {
|
|
|
13215
13650
|
}
|
|
13216
13651
|
/**
|
|
13217
13652
|
* Symbols the server knows for `query`, limited to paths the snapshot
|
|
13218
|
-
* exposes. Membership comes from `has`, never from reading the file: a
|
|
13653
|
+
* exposes, best `fuzzyRank` match first. Membership comes from `has`, never from reading the file: a
|
|
13219
13654
|
* per-keystroke search must not read every hit's text.
|
|
13220
13655
|
*/
|
|
13221
13656
|
async workspaceSymbols(query, limit = 200) {
|
|
@@ -13227,7 +13662,7 @@ var LspBridge = class LspBridge {
|
|
|
13227
13662
|
const seen = /* @__PURE__ */ new Set();
|
|
13228
13663
|
result.forEach((s, i) => {
|
|
13229
13664
|
const path = paths[i];
|
|
13230
|
-
if (path == null || !inside.get(path)
|
|
13665
|
+
if (path == null || !inside.get(path)) return;
|
|
13231
13666
|
const start = "range" in s.location ? s.location.range.start : {
|
|
13232
13667
|
line: 0,
|
|
13233
13668
|
character: 0
|
|
@@ -13246,7 +13681,7 @@ var LspBridge = class LspBridge {
|
|
|
13246
13681
|
col: start.character
|
|
13247
13682
|
});
|
|
13248
13683
|
});
|
|
13249
|
-
return out;
|
|
13684
|
+
return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
|
|
13250
13685
|
}
|
|
13251
13686
|
/**
|
|
13252
13687
|
* Contents of a file outside the snapshot that a recent result named as
|
|
@@ -13360,6 +13795,7 @@ var LspBridge = class LspBridge {
|
|
|
13360
13795
|
definition: { linkSupport: true },
|
|
13361
13796
|
typeDefinition: { linkSupport: true },
|
|
13362
13797
|
references: {},
|
|
13798
|
+
documentHighlight: {},
|
|
13363
13799
|
hover: { contentFormat: ["markdown", "plaintext"] },
|
|
13364
13800
|
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
13365
13801
|
semanticTokens: {
|
|
@@ -13945,11 +14381,14 @@ var LspPool = class {
|
|
|
13945
14381
|
async references(pos) {
|
|
13946
14382
|
return this.bridgeFor(pos.path).references(pos);
|
|
13947
14383
|
}
|
|
14384
|
+
async occurrences(pos) {
|
|
14385
|
+
return this.bridgeFor(pos.path).occurrences(pos);
|
|
14386
|
+
}
|
|
13948
14387
|
async documentSymbols(path) {
|
|
13949
14388
|
return this.bridgeFor(path).documentSymbols(path);
|
|
13950
14389
|
}
|
|
13951
14390
|
/**
|
|
13952
|
-
* Symbols matching `query` from every running server,
|
|
14391
|
+
* Symbols matching `query` from every running server, best `fuzzyRank` match first. One server
|
|
13953
14392
|
* failing drops its share; only an outage everywhere is an error.
|
|
13954
14393
|
*/
|
|
13955
14394
|
async workspaceSymbols(query, limit = 200) {
|
|
@@ -13959,7 +14398,7 @@ var LspPool = class {
|
|
|
13959
14398
|
const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
|
|
13960
14399
|
const failed = results.find((r) => r.status === "rejected");
|
|
13961
14400
|
if (!out.length && failed) throw failed.reason;
|
|
13962
|
-
return out.slice(0, limit);
|
|
14401
|
+
return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
|
|
13963
14402
|
}
|
|
13964
14403
|
/**
|
|
13965
14404
|
* Keeps the diff's files open in the server for their language, starting one for each
|
|
@@ -14044,6 +14483,7 @@ const WORKTREE = "worktree";
|
|
|
14044
14483
|
* "a..b" → a vs b
|
|
14045
14484
|
* "a...b" → merge-base(a, b) vs b
|
|
14046
14485
|
* "a" "b" → a vs b
|
|
14486
|
+
* "a^!" → a vs its first parent: the one commit, as git show shows it
|
|
14047
14487
|
* Empty sides default to HEAD. Either side accepts "worktree" for the uncommitted
|
|
14048
14488
|
* tree; a merge base against it is taken against HEAD, the commit it sits on.
|
|
14049
14489
|
*/
|
|
@@ -14052,34 +14492,45 @@ function parseRevspec(args) {
|
|
|
14052
14492
|
if (args.length === 2) {
|
|
14053
14493
|
const [a, b] = args;
|
|
14054
14494
|
if (a.includes("..") || b.includes("..")) throw new RevspecError("cannot combine \"..\" with two revisions");
|
|
14495
|
+
if (a.endsWith("^!") || b.endsWith("^!")) throw new RevspecError("cannot combine \"^!\" with two revisions");
|
|
14055
14496
|
return {
|
|
14056
14497
|
old: a,
|
|
14057
14498
|
new: b,
|
|
14058
|
-
|
|
14499
|
+
base: "direct"
|
|
14059
14500
|
};
|
|
14060
14501
|
}
|
|
14061
14502
|
const arg = args[0];
|
|
14503
|
+
if (arg.endsWith("^!")) {
|
|
14504
|
+
const commit = arg.slice(0, -2) || "HEAD";
|
|
14505
|
+
if (commit.includes("..")) throw new RevspecError("cannot combine \"^!\" with \"..\"");
|
|
14506
|
+
if (commit === WORKTREE) throw new RevspecError("the worktree is not a commit");
|
|
14507
|
+
return {
|
|
14508
|
+
old: commit,
|
|
14509
|
+
new: commit,
|
|
14510
|
+
base: "parent"
|
|
14511
|
+
};
|
|
14512
|
+
}
|
|
14062
14513
|
const three = arg.indexOf("...");
|
|
14063
14514
|
if (three !== -1) return {
|
|
14064
14515
|
old: arg.slice(0, three) || "HEAD",
|
|
14065
14516
|
new: arg.slice(three + 3) || "HEAD",
|
|
14066
|
-
|
|
14517
|
+
base: "merge-base"
|
|
14067
14518
|
};
|
|
14068
14519
|
const two = arg.indexOf("..");
|
|
14069
14520
|
if (two !== -1) return {
|
|
14070
14521
|
old: arg.slice(0, two) || "HEAD",
|
|
14071
14522
|
new: arg.slice(two + 2) || "HEAD",
|
|
14072
|
-
|
|
14523
|
+
base: "direct"
|
|
14073
14524
|
};
|
|
14074
14525
|
if (arg === WORKTREE) return {
|
|
14075
14526
|
old: "HEAD",
|
|
14076
14527
|
new: WORKTREE,
|
|
14077
|
-
|
|
14528
|
+
base: "direct"
|
|
14078
14529
|
};
|
|
14079
14530
|
return {
|
|
14080
14531
|
old: arg,
|
|
14081
14532
|
new: "HEAD",
|
|
14082
|
-
|
|
14533
|
+
base: "merge-base"
|
|
14083
14534
|
};
|
|
14084
14535
|
}
|
|
14085
14536
|
var RevspecError = class extends Error {};
|
|
@@ -17418,6 +17869,14 @@ function createApi(deps) {
|
|
|
17418
17869
|
await next();
|
|
17419
17870
|
});
|
|
17420
17871
|
app.get("/api/snapshot", async (c) => c.json(await session.snapshotter.current()));
|
|
17872
|
+
app.get("/api/moved", (c) => c.json(session.moved));
|
|
17873
|
+
app.post("/api/reload", async (c) => c.json(await session.reload()));
|
|
17874
|
+
app.delete("/api/iterations", async (c) => c.json(await session.clearIterations()));
|
|
17875
|
+
app.delete("/api/iterations/:n", async (c) => {
|
|
17876
|
+
const n = Number(c.req.param("n"));
|
|
17877
|
+
if (!Number.isInteger(n) || n < 1) throw new RevspecError(`not an iteration number: ${c.req.param("n")}`);
|
|
17878
|
+
return c.json(await session.deleteIteration(n));
|
|
17879
|
+
});
|
|
17421
17880
|
app.post("/api/mode", async (c) => {
|
|
17422
17881
|
const req = ModeRequestSchema.parse(await readJson(c));
|
|
17423
17882
|
return c.json(await session.switchMode(req));
|
|
@@ -17613,6 +18072,12 @@ function createApi(deps) {
|
|
|
17613
18072
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
17614
18073
|
return c.json(await r.lsp.references(pos));
|
|
17615
18074
|
});
|
|
18075
|
+
app.post("/api/lsp/occurrences", async (c) => {
|
|
18076
|
+
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18077
|
+
const r = await lspFor(pos.path);
|
|
18078
|
+
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18079
|
+
return c.json(await r.lsp.occurrences(pos));
|
|
18080
|
+
});
|
|
17616
18081
|
app.get("/api/lsp/symbols", async (c) => {
|
|
17617
18082
|
const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
|
|
17618
18083
|
const r = await lspFor(path);
|
|
@@ -17625,6 +18090,7 @@ function createApi(deps) {
|
|
|
17625
18090
|
await deps.config.set(body);
|
|
17626
18091
|
hub.broadcast({ type: "config" });
|
|
17627
18092
|
if (body.contextLines != null) await session.setContext(deps.config.get().contextLines);
|
|
18093
|
+
if (body.followRefs != null) session.setFollowRefs(deps.config.get().followRefs);
|
|
17628
18094
|
return c.json(effectiveConfig(deps));
|
|
17629
18095
|
});
|
|
17630
18096
|
return app;
|
|
@@ -17705,7 +18171,8 @@ var Server = class {
|
|
|
17705
18171
|
app.use("/api/*", compress());
|
|
17706
18172
|
app.route("/", createApi(this.deps));
|
|
17707
18173
|
app.all("/api/*", (c) => c.json({ error: "API endpoint not found. Restart diffle to load the latest server." }, 404));
|
|
17708
|
-
if (!this.opts.dev)
|
|
18174
|
+
if (!this.opts.dev && isSea()) app.get("*", serveEmbedded);
|
|
18175
|
+
else if (!this.opts.dev) {
|
|
17709
18176
|
const clientDir = resolveClientDir();
|
|
17710
18177
|
app.use("*", serveStatic({ root: clientDir }));
|
|
17711
18178
|
app.get("*", serveStatic({
|
|
@@ -17792,7 +18259,14 @@ function resolveClientDir() {
|
|
|
17792
18259
|
return join(projectRoot(), "dist", "client");
|
|
17793
18260
|
}
|
|
17794
18261
|
function hasClientBuild() {
|
|
17795
|
-
return existsSync(join(resolveClientDir(), "index.html"));
|
|
18262
|
+
return isSea() || existsSync(join(resolveClientDir(), "index.html"));
|
|
18263
|
+
}
|
|
18264
|
+
const embedded = isSea() ? new Set(getAssetKeys()) : /* @__PURE__ */ new Set();
|
|
18265
|
+
function serveEmbedded(c) {
|
|
18266
|
+
const path = c.req.path.slice(1);
|
|
18267
|
+
const key = embedded.has(path) ? path : "index.html";
|
|
18268
|
+
const type = getMimeType(key) ?? "application/octet-stream";
|
|
18269
|
+
return c.body(new Uint8Array(getRawAsset(key)), 200, { "content-type": type });
|
|
17796
18270
|
}
|
|
17797
18271
|
//#endregion
|
|
17798
18272
|
//#region src/server/github/pulls.ts
|
|
@@ -18296,6 +18770,102 @@ function describe(skipped) {
|
|
|
18296
18770
|
for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
|
|
18297
18771
|
return [...counts].map(([reason, n]) => `${n} ${reason}`).join(", ");
|
|
18298
18772
|
}
|
|
18773
|
+
const FileSchema = object({
|
|
18774
|
+
version: literal(1),
|
|
18775
|
+
ranges: record(string(), IterationSchema.array())
|
|
18776
|
+
});
|
|
18777
|
+
/**
|
|
18778
|
+
* The iterations of one range, under its comment key in `<gitDir>/diffle/iterations.json`.
|
|
18779
|
+
* Like the comment store, nothing is cached: every mutation is a read-modify-write of fresh
|
|
18780
|
+
* disk state under the file lock, so two servers on one repository keep each other's records.
|
|
18781
|
+
* Each iteration's commits are pinned under `refs/diffle/iterations/<key hash>/<n>/{old,new}`.
|
|
18782
|
+
*/
|
|
18783
|
+
var IterationStore = class {
|
|
18784
|
+
repo;
|
|
18785
|
+
key;
|
|
18786
|
+
file;
|
|
18787
|
+
refs;
|
|
18788
|
+
constructor(repo, key) {
|
|
18789
|
+
this.repo = repo;
|
|
18790
|
+
this.key = key;
|
|
18791
|
+
this.file = join(repo.gitDir, "diffle", "iterations.json");
|
|
18792
|
+
this.refs = `refs/diffle/iterations/${createHash("sha1").update(key).digest("hex").slice(0, 16)}`;
|
|
18793
|
+
}
|
|
18794
|
+
/** Oldest first. */
|
|
18795
|
+
async list() {
|
|
18796
|
+
return (await read(this.file)).ranges[this.key] ?? [];
|
|
18797
|
+
}
|
|
18798
|
+
/** The iteration numbered `n`, or null. */
|
|
18799
|
+
async get(n) {
|
|
18800
|
+
return (await this.list()).find((it) => it.n === n) ?? null;
|
|
18801
|
+
}
|
|
18802
|
+
/**
|
|
18803
|
+
* Records the endpoints as the next iteration, unless the latest already has them. Returns
|
|
18804
|
+
* the list afterwards. Pins the commits first, so a recorded iteration is always reachable.
|
|
18805
|
+
*/
|
|
18806
|
+
async record(oldSha, newSha) {
|
|
18807
|
+
return withFileLock(this.file, async () => {
|
|
18808
|
+
const all = await read(this.file);
|
|
18809
|
+
const list = all.ranges[this.key] ?? [];
|
|
18810
|
+
const last = list.at(-1);
|
|
18811
|
+
if (last && last.oldSha === oldSha && last.newSha === newSha) return list;
|
|
18812
|
+
const n = (last?.n ?? 0) + 1;
|
|
18813
|
+
const next = [...list, {
|
|
18814
|
+
n,
|
|
18815
|
+
oldSha,
|
|
18816
|
+
newSha,
|
|
18817
|
+
recordedAt: Date.now()
|
|
18818
|
+
}];
|
|
18819
|
+
const dropped = next.splice(0, Math.max(0, next.length - 50));
|
|
18820
|
+
await this.repo.pin({
|
|
18821
|
+
[`${this.refs}/${n}/old`]: oldSha,
|
|
18822
|
+
[`${this.refs}/${n}/new`]: newSha,
|
|
18823
|
+
...Object.fromEntries(dropped.flatMap((it) => [[`${this.refs}/${it.n}/old`, null], [`${this.refs}/${it.n}/new`, null]]))
|
|
18824
|
+
});
|
|
18825
|
+
all.ranges[this.key] = next;
|
|
18826
|
+
await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
|
|
18827
|
+
return next;
|
|
18828
|
+
});
|
|
18829
|
+
}
|
|
18830
|
+
/** Forgets the iteration numbered `n`, if recorded, and unpins its commits. The rest keep their numbers. */
|
|
18831
|
+
async remove(n) {
|
|
18832
|
+
return withFileLock(this.file, async () => {
|
|
18833
|
+
const all = await read(this.file);
|
|
18834
|
+
const list = all.ranges[this.key] ?? [];
|
|
18835
|
+
if (!list.some((it) => it.n === n)) return list;
|
|
18836
|
+
await this.repo.pin({
|
|
18837
|
+
[`${this.refs}/${n}/old`]: null,
|
|
18838
|
+
[`${this.refs}/${n}/new`]: null
|
|
18839
|
+
});
|
|
18840
|
+
const next = list.filter((it) => it.n !== n);
|
|
18841
|
+
if (next.length) all.ranges[this.key] = next;
|
|
18842
|
+
else delete all.ranges[this.key];
|
|
18843
|
+
await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
|
|
18844
|
+
return next;
|
|
18845
|
+
});
|
|
18846
|
+
}
|
|
18847
|
+
/** Forgets every iteration of this range and unpins their commits. */
|
|
18848
|
+
async clear() {
|
|
18849
|
+
await withFileLock(this.file, async () => {
|
|
18850
|
+
const all = await read(this.file);
|
|
18851
|
+
const list = all.ranges[this.key] ?? [];
|
|
18852
|
+
await this.repo.pin(Object.fromEntries(list.flatMap((it) => [[`${this.refs}/${it.n}/old`, null], [`${this.refs}/${it.n}/new`, null]])));
|
|
18853
|
+
delete all.ranges[this.key];
|
|
18854
|
+
await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
|
|
18855
|
+
});
|
|
18856
|
+
}
|
|
18857
|
+
};
|
|
18858
|
+
async function read(file) {
|
|
18859
|
+
try {
|
|
18860
|
+
return FileSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
|
18861
|
+
} catch (e) {
|
|
18862
|
+
if (e.code === "ENOENT") return {
|
|
18863
|
+
version: 1,
|
|
18864
|
+
ranges: {}
|
|
18865
|
+
};
|
|
18866
|
+
throw e;
|
|
18867
|
+
}
|
|
18868
|
+
}
|
|
18299
18869
|
//#endregion
|
|
18300
18870
|
//#region src/server/mode.ts
|
|
18301
18871
|
/**
|
|
@@ -18307,36 +18877,43 @@ function describe(skipped) {
|
|
|
18307
18877
|
* A--B--C main
|
|
18308
18878
|
* \--D--E HEAD
|
|
18309
18879
|
*
|
|
18310
|
-
* old new
|
|
18311
|
-
* main HEAD
|
|
18312
|
-
* main HEAD
|
|
18313
|
-
* HEAD worktree
|
|
18314
|
-
* worktree HEAD
|
|
18315
|
-
* main worktree
|
|
18316
|
-
* <C hash> <E hash>
|
|
18880
|
+
* old new base oldSha newSha live
|
|
18881
|
+
* main HEAD direct C E refs
|
|
18882
|
+
* main HEAD merge-base B E refs
|
|
18883
|
+
* HEAD worktree direct E worktree worktree
|
|
18884
|
+
* worktree HEAD direct worktree E worktree
|
|
18885
|
+
* main worktree merge-base B worktree worktree
|
|
18886
|
+
* <C hash> <E hash> direct C E none
|
|
18887
|
+
* <E hash> <E hash> parent D E none
|
|
18317
18888
|
*
|
|
18318
|
-
*
|
|
18319
|
-
* HEAD for any worktree input
|
|
18320
|
-
*
|
|
18889
|
+
* merge-base replaces only the old endpoint with the common ancestor, using
|
|
18890
|
+
* HEAD for any worktree input; parent replaces it with new's first parent, or
|
|
18891
|
+
* the empty tree for a root commit. live is "worktree" if either input is
|
|
18892
|
+
* worktree, "none" if both inputs are pinned hashes, and "refs" otherwise.
|
|
18321
18893
|
*
|
|
18322
18894
|
* In an unborn repository, HEAD compared directly with worktree resolves to
|
|
18323
18895
|
* the empty tree. Invalid revisions or missing merge bases throw RevspecError.
|
|
18324
18896
|
*/
|
|
18325
18897
|
async function resolveComparison(repo, comparison) {
|
|
18898
|
+
if (comparison.interdiff && comparison.base !== "parent") return {
|
|
18899
|
+
oldSha: comparison.old,
|
|
18900
|
+
newSha: comparison.new,
|
|
18901
|
+
live: "none"
|
|
18902
|
+
};
|
|
18326
18903
|
const endpoints = [comparison.old, comparison.new];
|
|
18327
18904
|
const resolve = async (rev) => {
|
|
18328
18905
|
if (rev === "worktree") return rev;
|
|
18329
18906
|
try {
|
|
18330
18907
|
return await repo.resolve(rev);
|
|
18331
18908
|
} catch (e) {
|
|
18332
|
-
if (
|
|
18909
|
+
if (comparison.base === "direct" && rev === "HEAD" && endpoints.includes("worktree") && e instanceof GitError && e.code === 1) return repo.emptyTree();
|
|
18333
18910
|
if (e instanceof GitError) throw new RevspecError(`unknown revision: ${rev}`);
|
|
18334
18911
|
throw e;
|
|
18335
18912
|
}
|
|
18336
18913
|
};
|
|
18337
18914
|
const [old, next] = await Promise.all([resolve(comparison.old), resolve(comparison.new)]);
|
|
18338
18915
|
let oldSha = old;
|
|
18339
|
-
if (comparison.
|
|
18916
|
+
if (comparison.base === "merge-base") {
|
|
18340
18917
|
const [a, b] = await Promise.all([old === "worktree" ? resolve("HEAD") : old, next === "worktree" ? resolve("HEAD") : next]);
|
|
18341
18918
|
try {
|
|
18342
18919
|
oldSha = await repo.mergeBase(a, b);
|
|
@@ -18345,6 +18922,13 @@ async function resolveComparison(repo, comparison) {
|
|
|
18345
18922
|
throw e;
|
|
18346
18923
|
}
|
|
18347
18924
|
}
|
|
18925
|
+
if (comparison.base === "parent") {
|
|
18926
|
+
if (next === "worktree") throw new RevspecError("the worktree is not a commit");
|
|
18927
|
+
oldSha = await repo.resolve(`${next}^`).catch((e) => {
|
|
18928
|
+
if (e instanceof GitError && e.code === 1) return repo.emptyTree();
|
|
18929
|
+
throw e;
|
|
18930
|
+
});
|
|
18931
|
+
}
|
|
18348
18932
|
const pinned = (rev, sha) => rev.length >= 7 && sha.startsWith(rev.toLowerCase());
|
|
18349
18933
|
return {
|
|
18350
18934
|
oldSha,
|
|
@@ -18352,14 +18936,29 @@ async function resolveComparison(repo, comparison) {
|
|
|
18352
18936
|
live: endpoints.includes("worktree") ? "worktree" : pinned(comparison.old, old) && pinned(comparison.new, next) ? "none" : "refs"
|
|
18353
18937
|
};
|
|
18354
18938
|
}
|
|
18939
|
+
/** Whether either endpoint names a local or remote branch, HEAD on one included. */
|
|
18940
|
+
async function namesBranch(repo, comparison) {
|
|
18941
|
+
const [old, next] = await Promise.all([repo.branchRef(comparison.old), repo.branchRef(comparison.new)]);
|
|
18942
|
+
return old != null || next != null;
|
|
18943
|
+
}
|
|
18355
18944
|
/** Resolves an input command into a comparison and optional explicit PR identity. */
|
|
18356
18945
|
async function resolveReview(req, repo, github) {
|
|
18357
18946
|
if (req.kind === "pr") return resolvePr(req, repo, github);
|
|
18358
18947
|
const parsed = req.kind === "working" ? {
|
|
18359
18948
|
old: "HEAD",
|
|
18360
18949
|
new: "worktree",
|
|
18361
|
-
|
|
18950
|
+
base: "direct"
|
|
18362
18951
|
} : parseRevspec(req.args);
|
|
18952
|
+
if (parsed.base === "parent") {
|
|
18953
|
+
const { newSha: sha } = await resolveComparison(repo, parsed);
|
|
18954
|
+
return { mode: {
|
|
18955
|
+
old: sha,
|
|
18956
|
+
new: sha,
|
|
18957
|
+
base: "parent",
|
|
18958
|
+
live: "none",
|
|
18959
|
+
commentKey: `commit:${sha}`
|
|
18960
|
+
} };
|
|
18961
|
+
}
|
|
18363
18962
|
const { oldSha, newSha, live } = await resolveComparison(repo, parsed);
|
|
18364
18963
|
return { mode: {
|
|
18365
18964
|
...parsed,
|
|
@@ -18369,19 +18968,21 @@ async function resolveReview(req, repo, github) {
|
|
|
18369
18968
|
}
|
|
18370
18969
|
/**
|
|
18371
18970
|
* Branch identities keep review state stable across pushes and local branch aliases.
|
|
18971
|
+
* Keys read like the comparison, with ... for a merge base and .. otherwise.
|
|
18372
18972
|
* For origin = github.com/o/r and HEAD on feat tracking origin/feat:
|
|
18373
|
-
* origin/main...HEAD →
|
|
18374
|
-
* origin/main..HEAD →
|
|
18973
|
+
* origin/main...HEAD → range:o/r:main...o/r:feat
|
|
18974
|
+
* origin/main..HEAD → range:o/r:main..o/r:feat
|
|
18375
18975
|
* The matching explicit PR uses the first key too. If feat tracks a fork,
|
|
18376
18976
|
* its identity becomes e.g. "contributor/r:feat", keeping forks distinct.
|
|
18377
18977
|
*
|
|
18378
18978
|
* Without a GitHub upstream, branches use full refs:
|
|
18379
|
-
* main...feat →
|
|
18979
|
+
* main...feat → range:refs/heads/main...refs/heads/feat
|
|
18380
18980
|
* Non-branch endpoints use their resolved hash or literal "worktree":
|
|
18381
|
-
* main..worktree →
|
|
18382
|
-
*
|
|
18981
|
+
* main..worktree → range:refs/heads/main..worktree
|
|
18982
|
+
* <C>..<E> → range:<C hash>..<E hash>
|
|
18383
18983
|
* Hash placeholders denote full resolved hashes, with oldSha already adjusted
|
|
18384
|
-
* for
|
|
18984
|
+
* for the merge base. The working command bypasses this helper and uses
|
|
18985
|
+
* "working"; a single commit uses "commit:<hash>".
|
|
18385
18986
|
*/
|
|
18386
18987
|
async function commentKey(repo, comparison, oldSha, newSha) {
|
|
18387
18988
|
const remotes = await repo.remotes();
|
|
@@ -18395,14 +18996,10 @@ async function commentKey(repo, comparison, oldSha, newSha) {
|
|
|
18395
18996
|
return repo.branchRef(rev);
|
|
18396
18997
|
};
|
|
18397
18998
|
const [old, next] = await Promise.all([identity(comparison.old), identity(comparison.new)]);
|
|
18398
|
-
return
|
|
18999
|
+
return rangeKey(old ?? oldSha, next ?? newSha, comparison.base === "merge-base");
|
|
18399
19000
|
}
|
|
18400
|
-
function
|
|
18401
|
-
return `
|
|
18402
|
-
old,
|
|
18403
|
-
next,
|
|
18404
|
-
mergeBase
|
|
18405
|
-
])}`;
|
|
19001
|
+
function rangeKey(old, next, mergeBase) {
|
|
19002
|
+
return `range:${old}${mergeBase ? "..." : ".."}${next}`;
|
|
18406
19003
|
}
|
|
18407
19004
|
/**
|
|
18408
19005
|
* A GitHub pull request, as GitHub shows it: merge-base(base tip, head) vs head.
|
|
@@ -18421,16 +19018,16 @@ async function resolvePr(req, repo, github) {
|
|
|
18421
19018
|
await resolveComparison(repo, {
|
|
18422
19019
|
old: base,
|
|
18423
19020
|
new: head,
|
|
18424
|
-
|
|
19021
|
+
base: "merge-base"
|
|
18425
19022
|
});
|
|
18426
19023
|
return {
|
|
18427
19024
|
prUrl: pr.url,
|
|
18428
19025
|
mode: {
|
|
18429
19026
|
old: base,
|
|
18430
19027
|
new: head,
|
|
18431
|
-
|
|
19028
|
+
base: "merge-base",
|
|
18432
19029
|
live: "none",
|
|
18433
|
-
commentKey:
|
|
19030
|
+
commentKey: rangeKey(`${pr.repository.toLowerCase()}:${pr.baseRefName}`, `${pr.headRepository?.toLowerCase() ?? `deleted:${pr.url}`}:${pr.headRefName}`, true)
|
|
18434
19031
|
}
|
|
18435
19032
|
};
|
|
18436
19033
|
}
|
|
@@ -18504,16 +19101,18 @@ var Snapshotter = class {
|
|
|
18504
19101
|
mode;
|
|
18505
19102
|
version;
|
|
18506
19103
|
context;
|
|
19104
|
+
iterations;
|
|
18507
19105
|
cache = null;
|
|
18508
19106
|
patches = /* @__PURE__ */ new Map();
|
|
18509
19107
|
all = null;
|
|
18510
19108
|
/** Content-sniff verdict by blob sha. A blob is immutable, so a refresh re-reads only new content. */
|
|
18511
19109
|
sniffed = /* @__PURE__ */ new Map();
|
|
18512
|
-
constructor(repo, mode, version, context) {
|
|
19110
|
+
constructor(repo, mode, version, context, iterations = null) {
|
|
18513
19111
|
this.repo = repo;
|
|
18514
19112
|
this.mode = mode;
|
|
18515
19113
|
this.version = version;
|
|
18516
19114
|
this.context = context;
|
|
19115
|
+
this.iterations = iterations;
|
|
18517
19116
|
}
|
|
18518
19117
|
/** Change context lines: drops cached patches; the next snapshot carries the new value. */
|
|
18519
19118
|
setContext(context, version) {
|
|
@@ -18579,8 +19178,13 @@ var Snapshotter = class {
|
|
|
18579
19178
|
}
|
|
18580
19179
|
async compute(version) {
|
|
18581
19180
|
const [{ oldSha, newSha }, headSha] = await Promise.all([resolveComparison(this.repo, this.mode), this.repo.resolve("HEAD").catch(() => "")]);
|
|
18582
|
-
const [tracked, changed] = await Promise.all([
|
|
18583
|
-
|
|
19181
|
+
const [tracked, changed, commit, commits] = await Promise.all([
|
|
19182
|
+
newSha === "worktree" ? Promise.all([this.repo.lsFiles(), this.repo.untracked()]).then((lists) => lists.flat().filter((p) => !p.endsWith("/"))) : this.repo.lsTree(newSha),
|
|
19183
|
+
this.repo.numstat(oldSha, newSha),
|
|
19184
|
+
this.mode.base === "parent" ? this.reviewedCommit(newSha) : null,
|
|
19185
|
+
this.rangeCommits(oldSha, newSha, headSha)
|
|
19186
|
+
]);
|
|
19187
|
+
const [iterations] = await Promise.all([this.recordIteration(commits), this.fillGenerated(changed, newSha)]);
|
|
18584
19188
|
const tree = new Set(tracked);
|
|
18585
19189
|
for (const f of changed) {
|
|
18586
19190
|
if (f.status === "D") tree.delete(f.path);
|
|
@@ -18596,11 +19200,49 @@ var Snapshotter = class {
|
|
|
18596
19200
|
headSha,
|
|
18597
19201
|
context: this.context,
|
|
18598
19202
|
changed,
|
|
18599
|
-
tree: [...tree].sort()
|
|
19203
|
+
tree: [...tree].sort(),
|
|
19204
|
+
commit,
|
|
19205
|
+
commits,
|
|
19206
|
+
iterations
|
|
18600
19207
|
};
|
|
18601
19208
|
for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
|
|
18602
19209
|
return snap;
|
|
18603
19210
|
}
|
|
19211
|
+
/** The snapshot taken is the range's current iteration; the worktree has no commit to record. */
|
|
19212
|
+
async recordIteration(commits) {
|
|
19213
|
+
if (!this.iterations || commits.oldSha === "worktree" || commits.newSha === "worktree") return [];
|
|
19214
|
+
return this.iterations.record(commits.oldSha, commits.newSha);
|
|
19215
|
+
}
|
|
19216
|
+
async reviewedCommit(sha) {
|
|
19217
|
+
const [info, neighbours] = await Promise.all([this.repo.commitInfo(sha), this.repo.commitNeighbours(sha)]);
|
|
19218
|
+
return info && {
|
|
19219
|
+
...info,
|
|
19220
|
+
...neighbours
|
|
19221
|
+
};
|
|
19222
|
+
}
|
|
19223
|
+
async rangeCommits(oldSha, newSha, headSha) {
|
|
19224
|
+
if (this.mode.within) {
|
|
19225
|
+
const range = await resolveComparison(this.repo, this.mode.within);
|
|
19226
|
+
return this.spanned(range.oldSha, range.newSha, headSha);
|
|
19227
|
+
}
|
|
19228
|
+
if (this.mode.base === "parent") return {
|
|
19229
|
+
...await this.repo.rangeCommits(`${newSha}^!`, 250),
|
|
19230
|
+
oldSha,
|
|
19231
|
+
newSha
|
|
19232
|
+
};
|
|
19233
|
+
return this.spanned(oldSha, newSha, headSha);
|
|
19234
|
+
}
|
|
19235
|
+
async spanned(oldSha, newSha, headSha) {
|
|
19236
|
+
const [from, to] = [oldSha, newSha].map((sha) => sha === "worktree" ? headSha : sha);
|
|
19237
|
+
return {
|
|
19238
|
+
...!from || !to || from === to ? {
|
|
19239
|
+
list: [],
|
|
19240
|
+
total: 0
|
|
19241
|
+
} : await this.repo.rangeCommits(`${from}..${to}`, 250),
|
|
19242
|
+
oldSha,
|
|
19243
|
+
newSha
|
|
19244
|
+
};
|
|
19245
|
+
}
|
|
18604
19246
|
/**
|
|
18605
19247
|
* Sets `generated` on each non-binary changed file. A `linguist-generated`
|
|
18606
19248
|
* gitattribute on the new side decides outright, as on GitHub; otherwise a
|
|
@@ -18649,6 +19291,10 @@ var Snapshotter = class {
|
|
|
18649
19291
|
* while its task holds the queue, so published versions are monotonic and the
|
|
18650
19292
|
* active snapshotter always carries the session's context. Requests that arrive
|
|
18651
19293
|
* before the first mode resolves await `ready()`.
|
|
19294
|
+
*
|
|
19295
|
+
* A worktree-live comparison recomputes on every change. A refs-live one only
|
|
19296
|
+
* announces that its refs moved (`moved`) and recomputes on `reload()`, so a
|
|
19297
|
+
* push or rebase does not swap the review out from under the reviewer.
|
|
18652
19298
|
*/
|
|
18653
19299
|
var Session = class {
|
|
18654
19300
|
repo;
|
|
@@ -18663,6 +19309,8 @@ var Session = class {
|
|
|
18663
19309
|
queue = Promise.resolve();
|
|
18664
19310
|
/** The refresh waiting for the queue, if any. Later requests join it. */
|
|
18665
19311
|
queuedRefresh = null;
|
|
19312
|
+
/** The refs check waiting for the queue, if any. Later signals join it. */
|
|
19313
|
+
queuedCheck = null;
|
|
18666
19314
|
readable = /* @__PURE__ */ new WeakMap();
|
|
18667
19315
|
snapshotListeners = /* @__PURE__ */ new Set();
|
|
18668
19316
|
constructor(repo, hub, opts) {
|
|
@@ -18684,6 +19332,24 @@ var Session = class {
|
|
|
18684
19332
|
get snapshotter() {
|
|
18685
19333
|
return this.require().snapshotter;
|
|
18686
19334
|
}
|
|
19335
|
+
/** The pending reload of a refs-live comparison, for a client that connects after the push. */
|
|
19336
|
+
get moved() {
|
|
19337
|
+
return this.require().moved;
|
|
19338
|
+
}
|
|
19339
|
+
/** Whether moved refs recompute the review, as worktree edits always do, or only announce themselves. */
|
|
19340
|
+
get follows() {
|
|
19341
|
+
const a = this.require();
|
|
19342
|
+
if (a.mode.live === "worktree") return true;
|
|
19343
|
+
switch (this.opts.followRefs) {
|
|
19344
|
+
case "on": return true;
|
|
19345
|
+
case "off": return false;
|
|
19346
|
+
case "auto": return !a.branches;
|
|
19347
|
+
}
|
|
19348
|
+
}
|
|
19349
|
+
/** Change the user's choice for the running session; a pending notice stands until a reload picks the refs up. */
|
|
19350
|
+
setFollowRefs(followRefs) {
|
|
19351
|
+
this.opts.followRefs = followRefs;
|
|
19352
|
+
}
|
|
18687
19353
|
/** Cached independently of snapshot construction; callers never hold the transition queue. */
|
|
18688
19354
|
github(snap) {
|
|
18689
19355
|
const active = this.require();
|
|
@@ -18771,7 +19437,7 @@ var Session = class {
|
|
|
18771
19437
|
}
|
|
18772
19438
|
/** Switch modes at runtime. Broadcasts a snapshot bump on success. */
|
|
18773
19439
|
async switchMode(req) {
|
|
18774
|
-
const snap = await this.run(async () => this.activate(await resolveReview(req, this.repo, this.opts.github)));
|
|
19440
|
+
const snap = await this.run(async () => this.activate(req.kind === "focus" ? await this.focus(req.commit) : req.kind === "interdiff" ? await this.interdiff(req.from, req.to) : req.kind === "pair" ? await this.pair(req.commit) : await resolveReview(req, this.repo, this.opts.github)));
|
|
18775
19441
|
this.hub.broadcast({
|
|
18776
19442
|
type: "snapshot",
|
|
18777
19443
|
version: snap.version
|
|
@@ -18779,6 +19445,150 @@ var Session = class {
|
|
|
18779
19445
|
return snap;
|
|
18780
19446
|
}
|
|
18781
19447
|
/**
|
|
19448
|
+
* Focuses `commit`, by hash or short hash, of the active range's listed commits, or with null returns to
|
|
19449
|
+
* the range. A focused commit keeps its own comments, as when entered alone, and the range's PR identity.
|
|
19450
|
+
*/
|
|
19451
|
+
async focus(commit) {
|
|
19452
|
+
const a = this.require();
|
|
19453
|
+
const { within, ...self } = a.mode;
|
|
19454
|
+
if (!within && self.base === "parent") throw new RevspecError("a single commit has no commits to focus");
|
|
19455
|
+
const range = within ?? self;
|
|
19456
|
+
if (commit === null) return {
|
|
19457
|
+
mode: range,
|
|
19458
|
+
prUrl: a.prUrl
|
|
19459
|
+
};
|
|
19460
|
+
const sha = (await a.snapshotter.current()).commits.list.find((c) => c.sha === commit || c.short === commit)?.sha;
|
|
19461
|
+
if (!sha) throw new RevspecError(`not a listed commit of ${comparisonLabel(range)}: ${commit}`);
|
|
19462
|
+
return {
|
|
19463
|
+
prUrl: a.prUrl,
|
|
19464
|
+
mode: {
|
|
19465
|
+
old: sha,
|
|
19466
|
+
new: sha,
|
|
19467
|
+
base: "parent",
|
|
19468
|
+
live: range.live === "none" ? "none" : "refs",
|
|
19469
|
+
commentKey: `commit:${sha}`,
|
|
19470
|
+
within: range
|
|
19471
|
+
}
|
|
19472
|
+
};
|
|
19473
|
+
}
|
|
19474
|
+
/**
|
|
19475
|
+
* Compares two recorded iterations of the active range: `from`'s head replayed onto `to`'s base
|
|
19476
|
+
* against `to`'s head, so only what the branch itself changed in between shows. The interdiff
|
|
19477
|
+
* keeps its own comments and the range's PR identity; its refs keep being watched.
|
|
19478
|
+
*/
|
|
19479
|
+
async interdiff(from, to) {
|
|
19480
|
+
const a = this.require();
|
|
19481
|
+
const range = a.mode.within ?? a.mode;
|
|
19482
|
+
if (range.base === "parent") throw new RevspecError("a single commit has no iterations");
|
|
19483
|
+
if (from >= to) throw new RevspecError("compare an older iteration with a newer one");
|
|
19484
|
+
const store = new IterationStore(this.repo, range.commentKey);
|
|
19485
|
+
const [older, newer] = await Promise.all([store.get(from), store.get(to)]);
|
|
19486
|
+
if (!older || !newer) throw new RevspecError(`no iteration #${older ? to : from} of ${comparisonLabel(range)}`);
|
|
19487
|
+
const [{ tree, conflicts }, pairs] = await Promise.all([older.oldSha === newer.oldSha ? this.repo.tree(older.newSha).then((tree) => ({
|
|
19488
|
+
tree,
|
|
19489
|
+
conflicts: []
|
|
19490
|
+
})) : this.repo.replay(older.oldSha, newer.oldSha, older.newSha), this.pairs(older, newer)]);
|
|
19491
|
+
return {
|
|
19492
|
+
prUrl: a.prUrl,
|
|
19493
|
+
mode: {
|
|
19494
|
+
old: tree,
|
|
19495
|
+
new: newer.newSha,
|
|
19496
|
+
base: "direct",
|
|
19497
|
+
live: range.live === "none" ? "none" : "refs",
|
|
19498
|
+
commentKey: `${range.commentKey}:interdiff:${from}-${to}`,
|
|
19499
|
+
within: range,
|
|
19500
|
+
interdiff: {
|
|
19501
|
+
from: older,
|
|
19502
|
+
to: newer,
|
|
19503
|
+
conflicts,
|
|
19504
|
+
pairs
|
|
19505
|
+
}
|
|
19506
|
+
}
|
|
19507
|
+
};
|
|
19508
|
+
}
|
|
19509
|
+
/**
|
|
19510
|
+
* Pairs the two iterations' commits as `git range-diff` does and tells a pair whose patch is
|
|
19511
|
+
* the same (a reworded commit) from an amended one by patch id.
|
|
19512
|
+
*/
|
|
19513
|
+
async pairs(older, newer) {
|
|
19514
|
+
const oldRange = `${older.oldSha}..${older.newSha}`;
|
|
19515
|
+
const newRange = `${newer.oldSha}..${newer.newSha}`;
|
|
19516
|
+
const [rows, oldCommits, newCommits] = await Promise.all([
|
|
19517
|
+
this.repo.rangeDiff(oldRange, newRange),
|
|
19518
|
+
this.repo.rangeCommits(oldRange, 250),
|
|
19519
|
+
this.repo.rangeCommits(newRange, 250)
|
|
19520
|
+
]);
|
|
19521
|
+
const find = async (list, abbrev) => {
|
|
19522
|
+
if (abbrev === null) return null;
|
|
19523
|
+
return list.find((c) => c.sha.startsWith(abbrev)) ?? (await this.repo.rangeCommits(`${abbrev}^!`, 1)).list[0] ?? null;
|
|
19524
|
+
};
|
|
19525
|
+
const paired = await Promise.all(rows.map(async (row) => ({
|
|
19526
|
+
old: await find(oldCommits.list, row.old),
|
|
19527
|
+
new: await find(newCommits.list, row.new),
|
|
19528
|
+
marker: row.marker
|
|
19529
|
+
})));
|
|
19530
|
+
const changed = paired.filter((p) => p.marker === "!" && p.old && p.new);
|
|
19531
|
+
const ids = await this.repo.patchIds(changed.flatMap((p) => [p.old.sha, p.new.sha]));
|
|
19532
|
+
return paired.map(({ old, new: next, marker }) => ({
|
|
19533
|
+
old,
|
|
19534
|
+
new: next,
|
|
19535
|
+
status: marker === "=" ? "identical" : marker === "<" ? "dropped" : marker === ">" ? "added" : old && next && ids.get(old.sha) !== void 0 && ids.get(old.sha) === ids.get(next.sha) ? "message" : "changed"
|
|
19536
|
+
}));
|
|
19537
|
+
}
|
|
19538
|
+
/**
|
|
19539
|
+
* Shows one pair of the active interdiff by its new-side commit: an added commit against its
|
|
19540
|
+
* parent, an amended or reworded one against its old self replayed onto its parent. With null,
|
|
19541
|
+
* the whole interdiff again. Identical pairs have nothing to show; dropped ones have no new side.
|
|
19542
|
+
*/
|
|
19543
|
+
async pair(commit) {
|
|
19544
|
+
const a = this.require();
|
|
19545
|
+
const { interdiff, within: range } = a.mode;
|
|
19546
|
+
if (!interdiff || !range) throw new RevspecError("no interdiff to pick a pair from");
|
|
19547
|
+
if (commit === null) return this.interdiff(interdiff.from.n, interdiff.to.n);
|
|
19548
|
+
const found = interdiff.pairs.find((p) => p.new && (p.new.sha === commit || p.new.short === commit));
|
|
19549
|
+
if (!found?.new) throw new RevspecError(`not a commit of ${comparisonLabel(a.mode)}: ${commit}`);
|
|
19550
|
+
if (found.status === "identical") throw new RevspecError(`${found.new.short} is identical in both iterations`);
|
|
19551
|
+
const base = {
|
|
19552
|
+
live: range.live === "none" ? "none" : "refs",
|
|
19553
|
+
within: range,
|
|
19554
|
+
interdiff
|
|
19555
|
+
};
|
|
19556
|
+
if (!found.old) {
|
|
19557
|
+
const sha = found.new.sha;
|
|
19558
|
+
return {
|
|
19559
|
+
prUrl: a.prUrl,
|
|
19560
|
+
mode: {
|
|
19561
|
+
...base,
|
|
19562
|
+
old: sha,
|
|
19563
|
+
new: sha,
|
|
19564
|
+
base: "parent",
|
|
19565
|
+
commentKey: `commit:${sha}`,
|
|
19566
|
+
pair: {
|
|
19567
|
+
old: null,
|
|
19568
|
+
new: sha,
|
|
19569
|
+
conflicts: []
|
|
19570
|
+
}
|
|
19571
|
+
}
|
|
19572
|
+
};
|
|
19573
|
+
}
|
|
19574
|
+
const { tree, conflicts } = await this.repo.replay(`${found.old.sha}^`, `${found.new.sha}^`, found.old.sha);
|
|
19575
|
+
return {
|
|
19576
|
+
prUrl: a.prUrl,
|
|
19577
|
+
mode: {
|
|
19578
|
+
...base,
|
|
19579
|
+
old: tree,
|
|
19580
|
+
new: found.new.sha,
|
|
19581
|
+
base: "direct",
|
|
19582
|
+
commentKey: `${range.commentKey}:pair:${found.old.sha}-${found.new.sha}`,
|
|
19583
|
+
pair: {
|
|
19584
|
+
old: found.old.sha,
|
|
19585
|
+
new: found.new.sha,
|
|
19586
|
+
conflicts
|
|
19587
|
+
}
|
|
19588
|
+
}
|
|
19589
|
+
};
|
|
19590
|
+
}
|
|
19591
|
+
/**
|
|
18782
19592
|
* Runs `fn` after every earlier mutation settled. The only way to touch
|
|
18783
19593
|
* `active`, `version` or `opts.context`.
|
|
18784
19594
|
*/
|
|
@@ -18788,14 +19598,22 @@ var Session = class {
|
|
|
18788
19598
|
return r;
|
|
18789
19599
|
}
|
|
18790
19600
|
async activate({ mode, prUrl }) {
|
|
18791
|
-
const
|
|
18792
|
-
const
|
|
19601
|
+
const range = mode.within ?? mode;
|
|
19602
|
+
const followed = range.base !== "parent" && (range.live === "refs" || prUrl != null);
|
|
19603
|
+
const snapshotter = new Snapshotter(this.repo, mode, ++this.version, this.opts.context, followed ? new IterationStore(this.repo, range.commentKey) : null);
|
|
19604
|
+
const [comments, snap, branches] = await Promise.all([
|
|
19605
|
+
CommentStore.open(this.repo.gitDir, mode.commentKey),
|
|
19606
|
+
snapshotter.current(),
|
|
19607
|
+
range.live === "refs" && namesBranch(this.repo, range)
|
|
19608
|
+
]);
|
|
18793
19609
|
const next = {
|
|
18794
19610
|
mode,
|
|
18795
19611
|
prUrl,
|
|
18796
19612
|
snapshotter,
|
|
18797
19613
|
comments,
|
|
18798
|
-
watcher: null
|
|
19614
|
+
watcher: null,
|
|
19615
|
+
moved: null,
|
|
19616
|
+
branches
|
|
18799
19617
|
};
|
|
18800
19618
|
await this.relocateComments(next, snap);
|
|
18801
19619
|
if (this.opts.watch && mode.live !== "none") next.watcher = await this.startWatcher(next);
|
|
@@ -18825,13 +19643,61 @@ var Session = class {
|
|
|
18825
19643
|
if (this.queuedRefresh) return this.queuedRefresh;
|
|
18826
19644
|
const r = this.run(async () => {
|
|
18827
19645
|
this.queuedRefresh = null;
|
|
18828
|
-
|
|
18829
|
-
a.snapshotter.invalidate(++this.version);
|
|
18830
|
-
await this.publish(a);
|
|
19646
|
+
await this.recompute();
|
|
18831
19647
|
}).catch((e) => console.error("[diffle] refresh failed:", e));
|
|
18832
19648
|
this.queuedRefresh = r;
|
|
18833
19649
|
return r;
|
|
18834
19650
|
}
|
|
19651
|
+
/** The reviewer asks for the moved refs: recompute now. Unlike `refresh`, a failure is the caller's. */
|
|
19652
|
+
reload() {
|
|
19653
|
+
return this.run(async () => {
|
|
19654
|
+
const a = this.require();
|
|
19655
|
+
if (!a.mode.interdiff || !a.mode.within) return this.recompute();
|
|
19656
|
+
return this.leaveInterdiff(a);
|
|
19657
|
+
});
|
|
19658
|
+
}
|
|
19659
|
+
/** Forgets the range's recorded iterations and unpins their commits; its current state is recorded anew as #1. */
|
|
19660
|
+
clearIterations() {
|
|
19661
|
+
return this.run(async () => {
|
|
19662
|
+
const a = this.require();
|
|
19663
|
+
const range = a.mode.within ?? a.mode;
|
|
19664
|
+
await new IterationStore(this.repo, range.commentKey).clear();
|
|
19665
|
+
return a.mode.interdiff ? this.leaveInterdiff(a) : this.recompute();
|
|
19666
|
+
});
|
|
19667
|
+
}
|
|
19668
|
+
/**
|
|
19669
|
+
* Forgets one recorded iteration and unpins its commits. The latest stays: it is the range's
|
|
19670
|
+
* current state and would be recorded again at once.
|
|
19671
|
+
*/
|
|
19672
|
+
deleteIteration(n) {
|
|
19673
|
+
return this.run(async () => {
|
|
19674
|
+
const a = this.require();
|
|
19675
|
+
const range = a.mode.within ?? a.mode;
|
|
19676
|
+
const store = new IterationStore(this.repo, range.commentKey);
|
|
19677
|
+
const list = await store.list();
|
|
19678
|
+
if (!list.some((it) => it.n === n)) throw new RevspecError(`no iteration #${n}`);
|
|
19679
|
+
if (list.at(-1).n === n) throw new RevspecError(`#${n} is the latest iteration, the range's current state`);
|
|
19680
|
+
await store.remove(n);
|
|
19681
|
+
const inter = a.mode.interdiff;
|
|
19682
|
+
return inter && (inter.from.n === n || inter.to.n === n) ? this.leaveInterdiff(a) : this.recompute();
|
|
19683
|
+
});
|
|
19684
|
+
}
|
|
19685
|
+
async leaveInterdiff(a) {
|
|
19686
|
+
const snap = await this.activate({
|
|
19687
|
+
mode: a.mode.within,
|
|
19688
|
+
prUrl: a.prUrl
|
|
19689
|
+
});
|
|
19690
|
+
this.hub.broadcast({
|
|
19691
|
+
type: "snapshot",
|
|
19692
|
+
version: snap.version
|
|
19693
|
+
});
|
|
19694
|
+
return snap;
|
|
19695
|
+
}
|
|
19696
|
+
recompute() {
|
|
19697
|
+
const a = this.require();
|
|
19698
|
+
a.snapshotter.invalidate(++this.version);
|
|
19699
|
+
return this.publish(a);
|
|
19700
|
+
}
|
|
18835
19701
|
/**
|
|
18836
19702
|
* Computes `a`'s current snapshot, re-anchors its threads (hunks may have
|
|
18837
19703
|
* grown, shrunk or moved), then broadcasts and announces it.
|
|
@@ -18839,11 +19705,40 @@ var Session = class {
|
|
|
18839
19705
|
async publish(a) {
|
|
18840
19706
|
const snap = await a.snapshotter.current();
|
|
18841
19707
|
await this.relocateComments(a, snap);
|
|
19708
|
+
a.moved = null;
|
|
18842
19709
|
this.hub.broadcast({
|
|
18843
19710
|
type: "snapshot",
|
|
18844
19711
|
version: snap.version
|
|
18845
19712
|
});
|
|
18846
19713
|
this.announce(snap);
|
|
19714
|
+
return snap;
|
|
19715
|
+
}
|
|
19716
|
+
/**
|
|
19717
|
+
* Resolves the refs the live comparison follows and tells clients when they
|
|
19718
|
+
* left the snapshot behind, or came back to it. At most one check waits
|
|
19719
|
+
* behind the running one, as with `refresh`.
|
|
19720
|
+
*/
|
|
19721
|
+
checkMoved(a) {
|
|
19722
|
+
if (this.queuedCheck) return this.queuedCheck;
|
|
19723
|
+
const r = this.run(async () => {
|
|
19724
|
+
this.queuedCheck = null;
|
|
19725
|
+
if (this.active !== a) return;
|
|
19726
|
+
const snap = await a.snapshotter.current();
|
|
19727
|
+
const { oldSha, newSha } = await resolveComparison(this.repo, a.mode.within ?? a.mode);
|
|
19728
|
+
const moved = oldSha === snap.commits.oldSha && newSha === snap.commits.newSha ? null : {
|
|
19729
|
+
version: snap.version,
|
|
19730
|
+
oldSha,
|
|
19731
|
+
newSha
|
|
19732
|
+
};
|
|
19733
|
+
if (moved?.oldSha === a.moved?.oldSha && moved?.newSha === a.moved?.newSha) return;
|
|
19734
|
+
a.moved = moved;
|
|
19735
|
+
this.hub.broadcast({
|
|
19736
|
+
type: "moved",
|
|
19737
|
+
moved
|
|
19738
|
+
});
|
|
19739
|
+
}).catch((e) => console.error("[diffle] refs check failed:", e));
|
|
19740
|
+
this.queuedCheck = r;
|
|
19741
|
+
return r;
|
|
18847
19742
|
}
|
|
18848
19743
|
/**
|
|
18849
19744
|
* Re-anchors threads against what `snap` shows. A changed file shows its
|
|
@@ -18907,6 +19802,7 @@ var Session = class {
|
|
|
18907
19802
|
* review. Goes through `readSide`, so the allowlist applies.
|
|
18908
19803
|
*/
|
|
18909
19804
|
anchorSource() {
|
|
19805
|
+
if (this.mode.interdiff) throw new RevspecError("comments are off while comparing iterations");
|
|
18910
19806
|
return {
|
|
18911
19807
|
quote: async (path, side, startLine, endLine) => {
|
|
18912
19808
|
const snap = await this.snapshotter.current();
|
|
@@ -18947,6 +19843,10 @@ var Session = class {
|
|
|
18947
19843
|
});
|
|
18948
19844
|
watcher.on("dirty", () => {
|
|
18949
19845
|
if (this.active !== a) return;
|
|
19846
|
+
if (!this.follows) {
|
|
19847
|
+
this.checkMoved(a);
|
|
19848
|
+
return;
|
|
19849
|
+
}
|
|
18950
19850
|
this.refresh();
|
|
18951
19851
|
this.run(async () => {
|
|
18952
19852
|
if (this.active === a && a.mode.live === "worktree") await refreshIgnored();
|
|
@@ -19037,6 +19937,7 @@ function normalize(c) {
|
|
|
19037
19937
|
return {
|
|
19038
19938
|
autoViewed: Array.isArray(c.autoViewed) ? c.autoViewed.filter((p) => typeof p === "string").map((p) => p.trim()).filter(Boolean) : DEFAULT_USER_CONFIG.autoViewed,
|
|
19039
19939
|
contextLines: typeof c.contextLines === "number" && Number.isFinite(c.contextLines) ? Math.max(0, Math.min(1e4, Math.floor(c.contextLines))) : DEFAULT_USER_CONFIG.contextLines,
|
|
19940
|
+
followRefs: FollowRefsSchema.safeParse(c.followRefs).data ?? DEFAULT_USER_CONFIG.followRefs,
|
|
19040
19941
|
lspCommands: lspCommands(c)
|
|
19041
19942
|
};
|
|
19042
19943
|
}
|
|
@@ -22744,6 +23645,12 @@ function parseLspOverride(raw) {
|
|
|
22744
23645
|
function collectLspOverride(raw, prev) {
|
|
22745
23646
|
return [...Array.isArray(prev) ? prev : [], parseLspOverride(raw)];
|
|
22746
23647
|
}
|
|
23648
|
+
/** A `set-follow-refs` value. */
|
|
23649
|
+
function parseFollowRefs(value) {
|
|
23650
|
+
const parsed = FollowRefsSchema.safeParse(value);
|
|
23651
|
+
if (!parsed.success) throw new InvalidArgumentError("expected on, auto or off");
|
|
23652
|
+
return parsed.data;
|
|
23653
|
+
}
|
|
22747
23654
|
//#endregion
|
|
22748
23655
|
//#region src/cli/browserLifetime.ts
|
|
22749
23656
|
/**
|
|
@@ -22895,6 +23802,7 @@ Shorthands (in place of <revs>):
|
|
|
22895
23802
|
working same as HEAD..worktree: uncommitted changes, staged and untracked included
|
|
22896
23803
|
pr [number|url] a GitHub pull request: its base...head, fetched if needed
|
|
22897
23804
|
(without an argument: the pull request for this branch)
|
|
23805
|
+
show [rev] same as <rev>^!: one commit against its first parent, pinned (default: HEAD)
|
|
22898
23806
|
|
|
22899
23807
|
Revisions follow git diff, except that a lone one compares from the merge base:
|
|
22900
23808
|
diffle main same as main...HEAD: what this branch added since it left main
|
|
@@ -22902,6 +23810,7 @@ Revisions follow git diff, except that a lone one compares from the merge base:
|
|
|
22902
23810
|
diffle main..feat main vs feat
|
|
22903
23811
|
diffle main...feat what feat added since it left main
|
|
22904
23812
|
diffle main..worktree main vs the uncommitted tree ("worktree" works on either side)
|
|
23813
|
+
diffle a1b2c3d^! the commit a1b2c3d alone, like git show
|
|
22905
23814
|
|
|
22906
23815
|
Without revisions, diffle compares HEAD..worktree.
|
|
22907
23816
|
|
|
@@ -22914,6 +23823,10 @@ Ctrl+C always stops it.`).action(async (revs, _o, cmd) => {
|
|
|
22914
23823
|
}, cmd.optsWithGlobals());
|
|
22915
23824
|
});
|
|
22916
23825
|
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()));
|
|
23826
|
+
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({
|
|
23827
|
+
kind: "revspec",
|
|
23828
|
+
args: [`${rev}^!`]
|
|
23829
|
+
}, cmd.optsWithGlobals()));
|
|
22917
23830
|
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
23831
|
kind: "pr",
|
|
22919
23832
|
pr
|
|
@@ -22934,6 +23847,11 @@ config.command("set-context").description("set context lines around changes").ar
|
|
|
22934
23847
|
await store.set({ contextLines: n });
|
|
22935
23848
|
console.log(store.get().contextLines);
|
|
22936
23849
|
});
|
|
23850
|
+
config.command("set-follow-refs").description("follow moved refs: on, auto (not branches), off").argument("<mode>", "on: reload at once; auto: so unless a branch is compared; off: offer a Reload button", parseFollowRefs).action(async (followRefs) => {
|
|
23851
|
+
const store = await UserConfigStore.open();
|
|
23852
|
+
await store.set({ followRefs });
|
|
23853
|
+
console.log(store.get().followRefs);
|
|
23854
|
+
});
|
|
22937
23855
|
config.command("set-lsp").description("set the language-server command for one language").addArgument(languageArgument("<language>", `one of: ${LANGUAGE_IDS.join(", ")}`).argParser(parseLanguage)).argument("<command>", "shell command that starts a stdio language server, e.g. \"pyrefly lsp\"; \"\" to turn it off").action(async (language, command) => {
|
|
22938
23856
|
const store = await UserConfigStore.open();
|
|
22939
23857
|
await store.set({ lspCommands: {
|
|
@@ -23009,6 +23927,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
|
|
|
23009
23927
|
const session = new Session(repo, hub, {
|
|
23010
23928
|
watch: opts.watch,
|
|
23011
23929
|
context: opts.context ?? config.get().contextLines,
|
|
23930
|
+
followRefs: config.get().followRefs,
|
|
23012
23931
|
github: github.client ?? void 0
|
|
23013
23932
|
});
|
|
23014
23933
|
const named = Array.isArray(opts.lsp) ? opts.lsp : [];
|
|
@@ -23071,7 +23990,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
|
|
|
23071
23990
|
console.error(`🔍 ${c.dim("comparing")} ${c.bold(comparisonLabel(snap.mode))}`);
|
|
23072
23991
|
if (n === 0) console.error(`${c.yellow("!")} No differences. Open any file from the tree to comment on it.`);
|
|
23073
23992
|
else console.error(`📝 ${n} changed file${n === 1 ? "" : "s"} ${c.green(`+${adds}`)} ${c.red(`−${dels}`)}`);
|
|
23074
|
-
if (snap.mode.live !== "none") console.error(`👀 ${c.dim(snap.mode.live === "worktree" ? "watching the worktree" : "watching refs")}${opts.watch ? "" : c.dim(" (disabled with --no-watch)")}`);
|
|
23993
|
+
if (snap.mode.live !== "none") console.error(`👀 ${c.dim(snap.mode.live === "worktree" ? "watching the worktree" : session.follows ? "watching refs" : "watching refs; moved refs show a Reload button")}${opts.watch ? "" : c.dim(" (disabled with --no-watch)")}`);
|
|
23075
23994
|
if (lsp) {
|
|
23076
23995
|
const { servers, missing } = lsp.status();
|
|
23077
23996
|
const names = servers.map((s) => `${s.name} ${c.dim(`(${s.languages.join(", ")})`)}`).join(", ");
|