@moritzwilksch/diffle 0.1.9 → 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 +4 -2
- package/dist/client/assets/{angular-html-CJY2n6PT.js → angular-html-NJfvXMBu.js} +1 -1
- package/dist/client/assets/{angular-ts-CZTM8_xo.js → angular-ts-3VmQ16GC.js} +1 -1
- package/dist/client/assets/{apl-Dcp77-Tz.js → apl-CaYQRkUE.js} +1 -1
- package/dist/client/assets/{astro-C0gdsdoF.js → astro-CGsy8jQJ.js} +1 -1
- package/dist/client/assets/{blade-BYpI0fWU.js → blade-CbyPPxmo.js} +1 -1
- package/dist/client/assets/{c-DvFVe1b3.js → c-BaJwvHpK.js} +1 -1
- package/dist/client/assets/{chapel-qAWjGK-V.js → chapel-BokLUCVc.js} +1 -1
- package/dist/client/assets/{cobol-BeFQKGES.js → cobol-I7_VFdfF.js} +1 -1
- package/dist/client/assets/{coffee-XLni1sjM.js → coffee-BJ7WLX7y.js} +1 -1
- package/dist/client/assets/{cpp-ChsJNYBC.js → cpp-DSFvuOlH.js} +1 -1
- package/dist/client/assets/{crystal-D3SbG8yD.js → crystal-DLLJ7kug.js} +1 -1
- package/dist/client/assets/{css-VXnGhPbY.js → css-CW7SLGV7.js} +1 -1
- package/dist/client/assets/{edge-Dbzmpk4W.js → edge-DQEyTxuu.js} +1 -1
- package/dist/client/assets/{elixir-DvQULd_0.js → elixir-Dwvydtjv.js} +1 -1
- package/dist/client/assets/{elm-BM5mpULp.js → elm-DPrZsI-D.js} +1 -1
- package/dist/client/assets/{erb-nYCgf5TS.js → erb-By39__m0.js} +1 -1
- package/dist/client/assets/{git-rebase-CcDsi2L8.js → git-rebase-CBF3buEb.js} +1 -1
- package/dist/client/assets/{glimmer-js-94igFmPU.js → glimmer-js-C4TvHlaw.js} +1 -1
- package/dist/client/assets/{glimmer-ts-8aOBlhVe.js → glimmer-ts-BPhq137Q.js} +1 -1
- package/dist/client/assets/{glsl-DAW_sXZj.js → glsl-BlwDtIwk.js} +1 -1
- package/dist/client/assets/{graphql-BEeRZxhy.js → graphql-BgMqs-o1.js} +1 -1
- package/dist/client/assets/{hack-e4pIPigB.js → hack-DrMUHl8k.js} +1 -1
- package/dist/client/assets/{haml-DvZH4eyc.js → haml-BWbuOop2.js} +1 -1
- package/dist/client/assets/{handlebars-Bod10MIb.js → handlebars-D-TP7YFr.js} +1 -1
- package/dist/client/assets/{html-BOKVgXUz.js → html-RkKdupbJ.js} +1 -1
- package/dist/client/assets/{html-derivative-B5DftdEs.js → html-derivative-BiXkRfVa.js} +1 -1
- package/dist/client/assets/{http-Bli9WHgJ.js → http-CF05Tv0R.js} +1 -1
- package/dist/client/assets/{hurl-C45hn_gr.js → hurl-p-K5zx-F.js} +1 -1
- package/dist/client/assets/index-C-uvIS11.css +2 -0
- package/dist/client/assets/index-Dw_tHgT2.js +3005 -0
- package/dist/client/assets/{java-BxezylI4.js → java-DZgPGAdX.js} +1 -1
- package/dist/client/assets/{javascript-CGUJoh42.js → javascript-UpC6uRwd.js} +1 -1
- package/dist/client/assets/{jinja-Dhrq1TMH.js → jinja-BwlD6UdE.js} +1 -1
- package/dist/client/assets/{jison-CK02w6aE.js → jison-CXzrtNYv.js} +1 -1
- package/dist/client/assets/{json-DbjMq68q.js → json-CvzeYtbd.js} +1 -1
- package/dist/client/assets/{jsx-BVYjBFnX.js → jsx-CRRNRy_v.js} +1 -1
- package/dist/client/assets/{julia-DUG0dPfw.js → julia-n9zQTAkT.js} +1 -1
- package/dist/client/assets/{just-BzILq64e.js → just-Cqgx3k16.js} +1 -1
- package/dist/client/assets/{latex-DJ-r-_gD.js → latex-CtWS0r44.js} +1 -1
- package/dist/client/assets/{liquid-CUeuBds8.js → liquid-DYOfxp51.js} +1 -1
- package/dist/client/assets/{lua-CUx56wFu.js → lua-BH2jjxbT.js} +1 -1
- package/dist/client/assets/{marko-zidFMx4y.js → marko-yGXqh8Mu.js} +1 -1
- package/dist/client/assets/{mdc-B8YDeE-G.js → mdc-DBTGzC9c.js} +1 -1
- package/dist/client/assets/{nginx-CLmnmibq.js → nginx-C6OOwetR.js} +1 -1
- package/dist/client/assets/{nim-D_ow61_F.js → nim-B-51OZwP.js} +1 -1
- package/dist/client/assets/{org-D6yFID6y.js → org-rdh6JnaZ.js} +1 -1
- package/dist/client/assets/{perl-DlRNqEum.js → perl-BEpLn7O_.js} +1 -1
- package/dist/client/assets/{php-CPe77eH-.js → php-uM1K2nIh.js} +1 -1
- package/dist/client/assets/{pug-CzDq8hOp.js → pug-CX5jxSmg.js} +1 -1
- package/dist/client/assets/{qml-CmVqwUZS.js → qml-BjaL9kT9.js} +1 -1
- package/dist/client/assets/{r-CZql-PDg.js → r-CbP_oADb.js} +1 -1
- package/dist/client/assets/{razor-Dp5yql5G.js → razor-ClgwHmG8.js} +1 -1
- package/dist/client/assets/{regexp-DjyDhvtX.js → regexp-DkGv1hXs.js} +1 -1
- package/dist/client/assets/{rst-B-Q6Ljb6.js → rst-KbuceK-d.js} +1 -1
- package/dist/client/assets/{ruby-BQD6FS2h.js → ruby-IyWK5TqJ.js} +1 -1
- package/dist/client/assets/{sas-DDGcym6k.js → sas-CGY91YIG.js} +1 -1
- package/dist/client/assets/{scss-DNPPx0rx.js → scss-C5azgOdY.js} +1 -1
- package/dist/client/assets/{shellscript-CHWpWo83.js → shellscript-Do9KSXv1.js} +1 -1
- package/dist/client/assets/{shellsession-_KE7LUMO.js → shellsession-DG2DOiiH.js} +1 -1
- package/dist/client/assets/{soy-B57xVh7W.js → soy-BLO2pcf_.js} +1 -1
- package/dist/client/assets/{sql-8OXlDHCR.js → sql-NhQC2aCy.js} +1 -1
- package/dist/client/assets/{stata-C452rT5l.js → stata-BT8bwSiP.js} +1 -1
- package/dist/client/assets/{surrealql-B5gNeOUk.js → surrealql-DeFUr0Sz.js} +1 -1
- package/dist/client/assets/{svelte-Cv_BwjgR.js → svelte-TRjcWU3h.js} +1 -1
- package/dist/client/assets/{templ-qZdOs42N.js → templ-C1Ef7mnt.js} +1 -1
- package/dist/client/assets/{tex-BK-MJpDM.js → tex-DWZKUBNC.js} +1 -1
- package/dist/client/assets/{ts-tags-C5Q-Olrs.js → ts-tags-BPGHRwAa.js} +1 -1
- package/dist/client/assets/{tsx-DwPm0lid.js → tsx-DFPjrnBq.js} +1 -1
- package/dist/client/assets/{twig-DKj0IG75.js → twig-BnaDylmD.js} +1 -1
- package/dist/client/assets/{typescript-BeWKsdVP.js → typescript-CtjU5LIe.js} +1 -1
- package/dist/client/assets/{typst-B7u-3Y0n.js → typst-CKVv919t.js} +1 -1
- package/dist/client/assets/{vue-BkC1F40W.js → vue-DQGhQ-pk.js} +1 -1
- package/dist/client/assets/{vue-html-BeYXHyr2.js → vue-html-BtU_VFsU.js} +1 -1
- package/dist/client/assets/{vue-vine-ymsHpgRC.js → vue-vine-Bg_4C7Q_.js} +1 -1
- package/dist/client/assets/{xml-B6M3xnfe.js → xml-DupdE_s3.js} +1 -1
- package/dist/client/assets/{xsl-CSlbm6tK.js → xsl-BkEIn0qW.js} +1 -1
- package/dist/client/assets/{yaml-fMwD58zK.js → yaml-BuH0PmqQ.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/main.js +764 -51
- package/package.json +1 -1
- package/dist/client/assets/index-BugDKSp3.js +0 -2994
- package/dist/client/assets/index-CvgG7WNQ.css +0 -2
package/dist/server/main.js
CHANGED
|
@@ -3952,7 +3952,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
|
|
|
3952
3952
|
}
|
|
3953
3953
|
//#endregion
|
|
3954
3954
|
//#region package.json
|
|
3955
|
-
var version$1 = "0.
|
|
3955
|
+
var version$1 = "0.2.0";
|
|
3956
3956
|
//#endregion
|
|
3957
3957
|
//#region src/server/comments/anchor.ts
|
|
3958
3958
|
function splitLines(contents) {
|
|
@@ -4109,6 +4109,12 @@ function patchArgs(oldRev) {
|
|
|
4109
4109
|
}
|
|
4110
4110
|
/** An idle `cat-file --batch` is reaped after this long; the next request respawns one. */
|
|
4111
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;
|
|
4112
4118
|
var GitError = class extends Error {
|
|
4113
4119
|
args;
|
|
4114
4120
|
code;
|
|
@@ -4185,6 +4191,16 @@ var GitRepo = class GitRepo {
|
|
|
4185
4191
|
`${rev}^{commit}`
|
|
4186
4192
|
])).trim();
|
|
4187
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
|
+
}
|
|
4188
4204
|
/** Commit messages for two ancestor offsets, pinned to one HEAD. */
|
|
4189
4205
|
async lastCommitsPreview(oldOffset, newOffset) {
|
|
4190
4206
|
const head = await this.commitInfo("HEAD");
|
|
@@ -4300,6 +4316,89 @@ var GitRepo = class GitRepo {
|
|
|
4300
4316
|
b
|
|
4301
4317
|
])).trim();
|
|
4302
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
|
+
}
|
|
4303
4402
|
/** origin/HEAD → main → master. */
|
|
4304
4403
|
async defaultBranch() {
|
|
4305
4404
|
try {
|
|
@@ -11890,6 +11989,20 @@ const ZodRecord = /*@__PURE__*/ $constructor("ZodRecord", (inst, def) => {
|
|
|
11890
11989
|
inst.keyType = def.keyType;
|
|
11891
11990
|
inst.valueType = def.valueType;
|
|
11892
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
|
+
}
|
|
11893
12006
|
function partialRecord(keyType, valueType, params) {
|
|
11894
12007
|
return new ZodRecord({
|
|
11895
12008
|
type: "record",
|
|
@@ -12165,9 +12278,31 @@ const ModeRequestSchema = discriminatedUnion("kind", [
|
|
|
12165
12278
|
object({
|
|
12166
12279
|
kind: literal("focus"),
|
|
12167
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()
|
|
12168
12290
|
})
|
|
12169
12291
|
]);
|
|
12170
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
|
+
/**
|
|
12171
12306
|
* How the old side is derived: `old` itself, merge-base(old, new) with worktree
|
|
12172
12307
|
* taken as HEAD, or new's first parent (the empty tree for a root commit).
|
|
12173
12308
|
*/
|
|
@@ -12189,18 +12324,88 @@ const ComparisonSchema = object({
|
|
|
12189
12324
|
/** Fixed when the comparison is entered; discovery never changes comment storage. */
|
|
12190
12325
|
commentKey: string()
|
|
12191
12326
|
});
|
|
12192
|
-
const
|
|
12193
|
-
|
|
12194
|
-
|
|
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
|
+
});
|
|
12195
12392
|
/**
|
|
12196
12393
|
* Display comparison endpoints with branch names instead of internal ref namespaces; a single commit as
|
|
12197
|
-
* `<sha>^!`,
|
|
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.
|
|
12198
12396
|
*/
|
|
12199
12397
|
function comparisonLabel(mode) {
|
|
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
|
+
}
|
|
12200
12402
|
if (mode.within) return `${comparisonLabel(mode.within)} @ ${mode.new.slice(0, 7)}`;
|
|
12201
12403
|
if (mode.base === "parent") return `${mode.new.slice(0, 7)}^!`;
|
|
12202
|
-
|
|
12203
|
-
|
|
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)\//, "");
|
|
12204
12409
|
}
|
|
12205
12410
|
const GithubPullRequestSchema = object({
|
|
12206
12411
|
repository: string(),
|
|
@@ -12250,31 +12455,17 @@ const ChangedFileSchema = object({
|
|
|
12250
12455
|
/** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
|
|
12251
12456
|
submodule: literal(true).optional()
|
|
12252
12457
|
});
|
|
12253
|
-
const CommitInfoSchema = object({
|
|
12254
|
-
sha: string(),
|
|
12255
|
-
short: string(),
|
|
12256
|
-
message: string()
|
|
12257
|
-
});
|
|
12258
|
-
/** A single reviewed commit and its neighbours along HEAD's first-parent history, for stepping through it. */
|
|
12259
|
-
const ReviewedCommitSchema = CommitInfoSchema.extend({
|
|
12260
|
-
/** First parent; null for a root commit. */
|
|
12261
|
-
parent: string().nullable(),
|
|
12262
|
-
/** The commit whose first parent this is, toward HEAD; null at HEAD or off HEAD's first-parent line. */
|
|
12263
|
-
child: string().nullable()
|
|
12264
|
-
});
|
|
12265
12458
|
/**
|
|
12266
12459
|
* The commits a comparison spans: old..new, with the worktree taken as HEAD, or the one commit under `parent`.
|
|
12267
12460
|
* A focused commit lists its range's.
|
|
12268
12461
|
*/
|
|
12269
12462
|
const RangeCommitsSchema = object({
|
|
12270
12463
|
/** Oldest first; the newest MAX_RANGE_COMMITS when there are more. */
|
|
12271
|
-
list:
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
}).array(),
|
|
12277
|
-
total: number()
|
|
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()
|
|
12278
12469
|
});
|
|
12279
12470
|
object({
|
|
12280
12471
|
root: string(),
|
|
@@ -12293,7 +12484,20 @@ object({
|
|
|
12293
12484
|
tree: string().array(),
|
|
12294
12485
|
/** The reviewed commit when the comparison is a single commit against its parent; otherwise null. */
|
|
12295
12486
|
commit: ReviewedCommitSchema.nullable(),
|
|
12296
|
-
commits: RangeCommitsSchema
|
|
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()
|
|
12297
12501
|
});
|
|
12298
12502
|
/** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
|
|
12299
12503
|
const PatchRequestSchema = object({ paths: string().array().max(200) });
|
|
@@ -12425,11 +12629,24 @@ object({
|
|
|
12425
12629
|
matches: SearchMatchSchema.array(),
|
|
12426
12630
|
truncated: boolean()
|
|
12427
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
|
+
]);
|
|
12428
12643
|
object({
|
|
12429
12644
|
/** Globs (picomatch syntax) for files that start viewed + collapsed. Empty by default. */
|
|
12430
12645
|
autoViewed: string().array(),
|
|
12431
12646
|
/** Unchanged lines shown around each change (git -U). Default 5. */
|
|
12432
12647
|
contextLines: number(),
|
|
12648
|
+
/** When a review between refs recomputes as its refs move; see `FollowRefsSchema`. Default auto. */
|
|
12649
|
+
followRefs: FollowRefsSchema,
|
|
12433
12650
|
/**
|
|
12434
12651
|
* Language server command per language, replacing the built-in candidate for it; an empty
|
|
12435
12652
|
* string disables the language. Read-only over HTTP; set with `diffle config set-lsp`.
|
|
@@ -12439,6 +12656,7 @@ object({
|
|
|
12439
12656
|
const DEFAULT_USER_CONFIG = {
|
|
12440
12657
|
autoViewed: [],
|
|
12441
12658
|
contextLines: 5,
|
|
12659
|
+
followRefs: "auto",
|
|
12442
12660
|
lspCommands: {}
|
|
12443
12661
|
};
|
|
12444
12662
|
/** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */
|
|
@@ -12509,6 +12727,18 @@ object({
|
|
|
12509
12727
|
object({
|
|
12510
12728
|
/** Null when the position is not inside a token or the server does not classify tokens. */
|
|
12511
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() });
|
|
12512
12742
|
object({
|
|
12513
12743
|
name: string(),
|
|
12514
12744
|
/** LSP SymbolKind number; the client maps it to a label. */
|
|
@@ -12531,6 +12761,10 @@ discriminatedUnion("type", [
|
|
|
12531
12761
|
type: literal("snapshot"),
|
|
12532
12762
|
version: number()
|
|
12533
12763
|
}),
|
|
12764
|
+
object({
|
|
12765
|
+
type: literal("moved"),
|
|
12766
|
+
moved: MovedSchema.nullable()
|
|
12767
|
+
}),
|
|
12534
12768
|
object({ type: literal("threads") }),
|
|
12535
12769
|
object({ type: literal("viewed") }),
|
|
12536
12770
|
object({ type: literal("config") }),
|
|
@@ -12624,7 +12858,8 @@ function noServer(lsp, language) {
|
|
|
12624
12858
|
/** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
|
|
12625
12859
|
const ConfigUpdateSchema = object({
|
|
12626
12860
|
autoViewed: string().array().optional(),
|
|
12627
|
-
contextLines: number().int().min(0).max(1e4).optional()
|
|
12861
|
+
contextLines: number().int().min(0).max(1e4).optional(),
|
|
12862
|
+
followRefs: FollowRefsSchema.optional()
|
|
12628
12863
|
});
|
|
12629
12864
|
const ResolvedRequestSchema = object({ resolved: boolean() });
|
|
12630
12865
|
const ViewedBulkRequestSchema = object({ entries: ViewedEntrySchema.array() });
|
|
@@ -12670,6 +12905,76 @@ const ClearThreadsQuerySchema = object({ stale: literal("1").optional() });
|
|
|
12670
12905
|
const PatchQuerySchema = object({ path: string().optional() });
|
|
12671
12906
|
object({ error: string() });
|
|
12672
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
|
|
12673
12978
|
//#region src/server/lsp/hover.ts
|
|
12674
12979
|
/**
|
|
12675
12980
|
* One markdown document for the tooltip, or null when the server sent nothing
|
|
@@ -13239,6 +13544,30 @@ var LspBridge = class LspBridge {
|
|
|
13239
13544
|
}
|
|
13240
13545
|
return { kind: null };
|
|
13241
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
|
+
}
|
|
13242
13571
|
async references(pos, opts = {}) {
|
|
13243
13572
|
const result = await this.query("textDocument/references", {
|
|
13244
13573
|
textDocument: { uri: this.uri(pos.path) },
|
|
@@ -13321,7 +13650,7 @@ var LspBridge = class LspBridge {
|
|
|
13321
13650
|
}
|
|
13322
13651
|
/**
|
|
13323
13652
|
* Symbols the server knows for `query`, limited to paths the snapshot
|
|
13324
|
-
* 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
|
|
13325
13654
|
* per-keystroke search must not read every hit's text.
|
|
13326
13655
|
*/
|
|
13327
13656
|
async workspaceSymbols(query, limit = 200) {
|
|
@@ -13333,7 +13662,7 @@ var LspBridge = class LspBridge {
|
|
|
13333
13662
|
const seen = /* @__PURE__ */ new Set();
|
|
13334
13663
|
result.forEach((s, i) => {
|
|
13335
13664
|
const path = paths[i];
|
|
13336
|
-
if (path == null || !inside.get(path)
|
|
13665
|
+
if (path == null || !inside.get(path)) return;
|
|
13337
13666
|
const start = "range" in s.location ? s.location.range.start : {
|
|
13338
13667
|
line: 0,
|
|
13339
13668
|
character: 0
|
|
@@ -13352,7 +13681,7 @@ var LspBridge = class LspBridge {
|
|
|
13352
13681
|
col: start.character
|
|
13353
13682
|
});
|
|
13354
13683
|
});
|
|
13355
|
-
return out;
|
|
13684
|
+
return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
|
|
13356
13685
|
}
|
|
13357
13686
|
/**
|
|
13358
13687
|
* Contents of a file outside the snapshot that a recent result named as
|
|
@@ -13466,6 +13795,7 @@ var LspBridge = class LspBridge {
|
|
|
13466
13795
|
definition: { linkSupport: true },
|
|
13467
13796
|
typeDefinition: { linkSupport: true },
|
|
13468
13797
|
references: {},
|
|
13798
|
+
documentHighlight: {},
|
|
13469
13799
|
hover: { contentFormat: ["markdown", "plaintext"] },
|
|
13470
13800
|
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
13471
13801
|
semanticTokens: {
|
|
@@ -14051,11 +14381,14 @@ var LspPool = class {
|
|
|
14051
14381
|
async references(pos) {
|
|
14052
14382
|
return this.bridgeFor(pos.path).references(pos);
|
|
14053
14383
|
}
|
|
14384
|
+
async occurrences(pos) {
|
|
14385
|
+
return this.bridgeFor(pos.path).occurrences(pos);
|
|
14386
|
+
}
|
|
14054
14387
|
async documentSymbols(path) {
|
|
14055
14388
|
return this.bridgeFor(path).documentSymbols(path);
|
|
14056
14389
|
}
|
|
14057
14390
|
/**
|
|
14058
|
-
* Symbols matching `query` from every running server,
|
|
14391
|
+
* Symbols matching `query` from every running server, best `fuzzyRank` match first. One server
|
|
14059
14392
|
* failing drops its share; only an outage everywhere is an error.
|
|
14060
14393
|
*/
|
|
14061
14394
|
async workspaceSymbols(query, limit = 200) {
|
|
@@ -14065,7 +14398,7 @@ var LspPool = class {
|
|
|
14065
14398
|
const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
|
|
14066
14399
|
const failed = results.find((r) => r.status === "rejected");
|
|
14067
14400
|
if (!out.length && failed) throw failed.reason;
|
|
14068
|
-
return out.slice(0, limit);
|
|
14401
|
+
return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
|
|
14069
14402
|
}
|
|
14070
14403
|
/**
|
|
14071
14404
|
* Keeps the diff's files open in the server for their language, starting one for each
|
|
@@ -17536,6 +17869,14 @@ function createApi(deps) {
|
|
|
17536
17869
|
await next();
|
|
17537
17870
|
});
|
|
17538
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
|
+
});
|
|
17539
17880
|
app.post("/api/mode", async (c) => {
|
|
17540
17881
|
const req = ModeRequestSchema.parse(await readJson(c));
|
|
17541
17882
|
return c.json(await session.switchMode(req));
|
|
@@ -17731,6 +18072,12 @@ function createApi(deps) {
|
|
|
17731
18072
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
17732
18073
|
return c.json(await r.lsp.references(pos));
|
|
17733
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
|
+
});
|
|
17734
18081
|
app.get("/api/lsp/symbols", async (c) => {
|
|
17735
18082
|
const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
|
|
17736
18083
|
const r = await lspFor(path);
|
|
@@ -17743,6 +18090,7 @@ function createApi(deps) {
|
|
|
17743
18090
|
await deps.config.set(body);
|
|
17744
18091
|
hub.broadcast({ type: "config" });
|
|
17745
18092
|
if (body.contextLines != null) await session.setContext(deps.config.get().contextLines);
|
|
18093
|
+
if (body.followRefs != null) session.setFollowRefs(deps.config.get().followRefs);
|
|
17746
18094
|
return c.json(effectiveConfig(deps));
|
|
17747
18095
|
});
|
|
17748
18096
|
return app;
|
|
@@ -18422,6 +18770,102 @@ function describe(skipped) {
|
|
|
18422
18770
|
for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
|
|
18423
18771
|
return [...counts].map(([reason, n]) => `${n} ${reason}`).join(", ");
|
|
18424
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
|
+
}
|
|
18425
18869
|
//#endregion
|
|
18426
18870
|
//#region src/server/mode.ts
|
|
18427
18871
|
/**
|
|
@@ -18451,6 +18895,11 @@ function describe(skipped) {
|
|
|
18451
18895
|
* the empty tree. Invalid revisions or missing merge bases throw RevspecError.
|
|
18452
18896
|
*/
|
|
18453
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
|
+
};
|
|
18454
18903
|
const endpoints = [comparison.old, comparison.new];
|
|
18455
18904
|
const resolve = async (rev) => {
|
|
18456
18905
|
if (rev === "worktree") return rev;
|
|
@@ -18487,6 +18936,11 @@ async function resolveComparison(repo, comparison) {
|
|
|
18487
18936
|
live: endpoints.includes("worktree") ? "worktree" : pinned(comparison.old, old) && pinned(comparison.new, next) ? "none" : "refs"
|
|
18488
18937
|
};
|
|
18489
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
|
+
}
|
|
18490
18944
|
/** Resolves an input command into a comparison and optional explicit PR identity. */
|
|
18491
18945
|
async function resolveReview(req, repo, github) {
|
|
18492
18946
|
if (req.kind === "pr") return resolvePr(req, repo, github);
|
|
@@ -18647,16 +19101,18 @@ var Snapshotter = class {
|
|
|
18647
19101
|
mode;
|
|
18648
19102
|
version;
|
|
18649
19103
|
context;
|
|
19104
|
+
iterations;
|
|
18650
19105
|
cache = null;
|
|
18651
19106
|
patches = /* @__PURE__ */ new Map();
|
|
18652
19107
|
all = null;
|
|
18653
19108
|
/** Content-sniff verdict by blob sha. A blob is immutable, so a refresh re-reads only new content. */
|
|
18654
19109
|
sniffed = /* @__PURE__ */ new Map();
|
|
18655
|
-
constructor(repo, mode, version, context) {
|
|
19110
|
+
constructor(repo, mode, version, context, iterations = null) {
|
|
18656
19111
|
this.repo = repo;
|
|
18657
19112
|
this.mode = mode;
|
|
18658
19113
|
this.version = version;
|
|
18659
19114
|
this.context = context;
|
|
19115
|
+
this.iterations = iterations;
|
|
18660
19116
|
}
|
|
18661
19117
|
/** Change context lines: drops cached patches; the next snapshot carries the new value. */
|
|
18662
19118
|
setContext(context, version) {
|
|
@@ -18728,7 +19184,7 @@ var Snapshotter = class {
|
|
|
18728
19184
|
this.mode.base === "parent" ? this.reviewedCommit(newSha) : null,
|
|
18729
19185
|
this.rangeCommits(oldSha, newSha, headSha)
|
|
18730
19186
|
]);
|
|
18731
|
-
await this.fillGenerated(changed, newSha);
|
|
19187
|
+
const [iterations] = await Promise.all([this.recordIteration(commits), this.fillGenerated(changed, newSha)]);
|
|
18732
19188
|
const tree = new Set(tracked);
|
|
18733
19189
|
for (const f of changed) {
|
|
18734
19190
|
if (f.status === "D") tree.delete(f.path);
|
|
@@ -18746,11 +19202,17 @@ var Snapshotter = class {
|
|
|
18746
19202
|
changed,
|
|
18747
19203
|
tree: [...tree].sort(),
|
|
18748
19204
|
commit,
|
|
18749
|
-
commits
|
|
19205
|
+
commits,
|
|
19206
|
+
iterations
|
|
18750
19207
|
};
|
|
18751
19208
|
for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
|
|
18752
19209
|
return snap;
|
|
18753
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
|
+
}
|
|
18754
19216
|
async reviewedCommit(sha) {
|
|
18755
19217
|
const [info, neighbours] = await Promise.all([this.repo.commitInfo(sha), this.repo.commitNeighbours(sha)]);
|
|
18756
19218
|
return info && {
|
|
@@ -18763,16 +19225,23 @@ var Snapshotter = class {
|
|
|
18763
19225
|
const range = await resolveComparison(this.repo, this.mode.within);
|
|
18764
19226
|
return this.spanned(range.oldSha, range.newSha, headSha);
|
|
18765
19227
|
}
|
|
18766
|
-
if (this.mode.base === "parent") return
|
|
19228
|
+
if (this.mode.base === "parent") return {
|
|
19229
|
+
...await this.repo.rangeCommits(`${newSha}^!`, 250),
|
|
19230
|
+
oldSha,
|
|
19231
|
+
newSha
|
|
19232
|
+
};
|
|
18767
19233
|
return this.spanned(oldSha, newSha, headSha);
|
|
18768
19234
|
}
|
|
18769
|
-
spanned(oldSha, newSha, headSha) {
|
|
19235
|
+
async spanned(oldSha, newSha, headSha) {
|
|
18770
19236
|
const [from, to] = [oldSha, newSha].map((sha) => sha === "worktree" ? headSha : sha);
|
|
18771
|
-
|
|
18772
|
-
|
|
18773
|
-
|
|
18774
|
-
|
|
18775
|
-
|
|
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
|
+
};
|
|
18776
19245
|
}
|
|
18777
19246
|
/**
|
|
18778
19247
|
* Sets `generated` on each non-binary changed file. A `linguist-generated`
|
|
@@ -18822,6 +19291,10 @@ var Snapshotter = class {
|
|
|
18822
19291
|
* while its task holds the queue, so published versions are monotonic and the
|
|
18823
19292
|
* active snapshotter always carries the session's context. Requests that arrive
|
|
18824
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.
|
|
18825
19298
|
*/
|
|
18826
19299
|
var Session = class {
|
|
18827
19300
|
repo;
|
|
@@ -18836,6 +19309,8 @@ var Session = class {
|
|
|
18836
19309
|
queue = Promise.resolve();
|
|
18837
19310
|
/** The refresh waiting for the queue, if any. Later requests join it. */
|
|
18838
19311
|
queuedRefresh = null;
|
|
19312
|
+
/** The refs check waiting for the queue, if any. Later signals join it. */
|
|
19313
|
+
queuedCheck = null;
|
|
18839
19314
|
readable = /* @__PURE__ */ new WeakMap();
|
|
18840
19315
|
snapshotListeners = /* @__PURE__ */ new Set();
|
|
18841
19316
|
constructor(repo, hub, opts) {
|
|
@@ -18857,6 +19332,24 @@ var Session = class {
|
|
|
18857
19332
|
get snapshotter() {
|
|
18858
19333
|
return this.require().snapshotter;
|
|
18859
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
|
+
}
|
|
18860
19353
|
/** Cached independently of snapshot construction; callers never hold the transition queue. */
|
|
18861
19354
|
github(snap) {
|
|
18862
19355
|
const active = this.require();
|
|
@@ -18944,7 +19437,7 @@ var Session = class {
|
|
|
18944
19437
|
}
|
|
18945
19438
|
/** Switch modes at runtime. Broadcasts a snapshot bump on success. */
|
|
18946
19439
|
async switchMode(req) {
|
|
18947
|
-
const snap = await this.run(async () => this.activate(req.kind === "focus" ? await this.focus(req.commit) : await resolveReview(req, this.repo, this.opts.github)));
|
|
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)));
|
|
18948
19441
|
this.hub.broadcast({
|
|
18949
19442
|
type: "snapshot",
|
|
18950
19443
|
version: snap.version
|
|
@@ -18979,6 +19472,123 @@ var Session = class {
|
|
|
18979
19472
|
};
|
|
18980
19473
|
}
|
|
18981
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
|
+
/**
|
|
18982
19592
|
* Runs `fn` after every earlier mutation settled. The only way to touch
|
|
18983
19593
|
* `active`, `version` or `opts.context`.
|
|
18984
19594
|
*/
|
|
@@ -18988,14 +19598,22 @@ var Session = class {
|
|
|
18988
19598
|
return r;
|
|
18989
19599
|
}
|
|
18990
19600
|
async activate({ mode, prUrl }) {
|
|
18991
|
-
const
|
|
18992
|
-
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
|
+
]);
|
|
18993
19609
|
const next = {
|
|
18994
19610
|
mode,
|
|
18995
19611
|
prUrl,
|
|
18996
19612
|
snapshotter,
|
|
18997
19613
|
comments,
|
|
18998
|
-
watcher: null
|
|
19614
|
+
watcher: null,
|
|
19615
|
+
moved: null,
|
|
19616
|
+
branches
|
|
18999
19617
|
};
|
|
19000
19618
|
await this.relocateComments(next, snap);
|
|
19001
19619
|
if (this.opts.watch && mode.live !== "none") next.watcher = await this.startWatcher(next);
|
|
@@ -19025,13 +19643,61 @@ var Session = class {
|
|
|
19025
19643
|
if (this.queuedRefresh) return this.queuedRefresh;
|
|
19026
19644
|
const r = this.run(async () => {
|
|
19027
19645
|
this.queuedRefresh = null;
|
|
19028
|
-
|
|
19029
|
-
a.snapshotter.invalidate(++this.version);
|
|
19030
|
-
await this.publish(a);
|
|
19646
|
+
await this.recompute();
|
|
19031
19647
|
}).catch((e) => console.error("[diffle] refresh failed:", e));
|
|
19032
19648
|
this.queuedRefresh = r;
|
|
19033
19649
|
return r;
|
|
19034
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
|
+
}
|
|
19035
19701
|
/**
|
|
19036
19702
|
* Computes `a`'s current snapshot, re-anchors its threads (hunks may have
|
|
19037
19703
|
* grown, shrunk or moved), then broadcasts and announces it.
|
|
@@ -19039,11 +19705,40 @@ var Session = class {
|
|
|
19039
19705
|
async publish(a) {
|
|
19040
19706
|
const snap = await a.snapshotter.current();
|
|
19041
19707
|
await this.relocateComments(a, snap);
|
|
19708
|
+
a.moved = null;
|
|
19042
19709
|
this.hub.broadcast({
|
|
19043
19710
|
type: "snapshot",
|
|
19044
19711
|
version: snap.version
|
|
19045
19712
|
});
|
|
19046
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;
|
|
19047
19742
|
}
|
|
19048
19743
|
/**
|
|
19049
19744
|
* Re-anchors threads against what `snap` shows. A changed file shows its
|
|
@@ -19107,6 +19802,7 @@ var Session = class {
|
|
|
19107
19802
|
* review. Goes through `readSide`, so the allowlist applies.
|
|
19108
19803
|
*/
|
|
19109
19804
|
anchorSource() {
|
|
19805
|
+
if (this.mode.interdiff) throw new RevspecError("comments are off while comparing iterations");
|
|
19110
19806
|
return {
|
|
19111
19807
|
quote: async (path, side, startLine, endLine) => {
|
|
19112
19808
|
const snap = await this.snapshotter.current();
|
|
@@ -19147,6 +19843,10 @@ var Session = class {
|
|
|
19147
19843
|
});
|
|
19148
19844
|
watcher.on("dirty", () => {
|
|
19149
19845
|
if (this.active !== a) return;
|
|
19846
|
+
if (!this.follows) {
|
|
19847
|
+
this.checkMoved(a);
|
|
19848
|
+
return;
|
|
19849
|
+
}
|
|
19150
19850
|
this.refresh();
|
|
19151
19851
|
this.run(async () => {
|
|
19152
19852
|
if (this.active === a && a.mode.live === "worktree") await refreshIgnored();
|
|
@@ -19237,6 +19937,7 @@ function normalize(c) {
|
|
|
19237
19937
|
return {
|
|
19238
19938
|
autoViewed: Array.isArray(c.autoViewed) ? c.autoViewed.filter((p) => typeof p === "string").map((p) => p.trim()).filter(Boolean) : DEFAULT_USER_CONFIG.autoViewed,
|
|
19239
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,
|
|
19240
19941
|
lspCommands: lspCommands(c)
|
|
19241
19942
|
};
|
|
19242
19943
|
}
|
|
@@ -22944,6 +23645,12 @@ function parseLspOverride(raw) {
|
|
|
22944
23645
|
function collectLspOverride(raw, prev) {
|
|
22945
23646
|
return [...Array.isArray(prev) ? prev : [], parseLspOverride(raw)];
|
|
22946
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
|
+
}
|
|
22947
23654
|
//#endregion
|
|
22948
23655
|
//#region src/cli/browserLifetime.ts
|
|
22949
23656
|
/**
|
|
@@ -23140,6 +23847,11 @@ config.command("set-context").description("set context lines around changes").ar
|
|
|
23140
23847
|
await store.set({ contextLines: n });
|
|
23141
23848
|
console.log(store.get().contextLines);
|
|
23142
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
|
+
});
|
|
23143
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) => {
|
|
23144
23856
|
const store = await UserConfigStore.open();
|
|
23145
23857
|
await store.set({ lspCommands: {
|
|
@@ -23215,6 +23927,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
|
|
|
23215
23927
|
const session = new Session(repo, hub, {
|
|
23216
23928
|
watch: opts.watch,
|
|
23217
23929
|
context: opts.context ?? config.get().contextLines,
|
|
23930
|
+
followRefs: config.get().followRefs,
|
|
23218
23931
|
github: github.client ?? void 0
|
|
23219
23932
|
});
|
|
23220
23933
|
const named = Array.isArray(opts.lsp) ? opts.lsp : [];
|
|
@@ -23277,7 +23990,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
|
|
|
23277
23990
|
console.error(`🔍 ${c.dim("comparing")} ${c.bold(comparisonLabel(snap.mode))}`);
|
|
23278
23991
|
if (n === 0) console.error(`${c.yellow("!")} No differences. Open any file from the tree to comment on it.`);
|
|
23279
23992
|
else console.error(`📝 ${n} changed file${n === 1 ? "" : "s"} ${c.green(`+${adds}`)} ${c.red(`−${dels}`)}`);
|
|
23280
|
-
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)")}`);
|
|
23281
23994
|
if (lsp) {
|
|
23282
23995
|
const { servers, missing } = lsp.status();
|
|
23283
23996
|
const names = servers.map((s) => `${s.name} ${c.dim(`(${s.languages.join(", ")})`)}`).join(", ");
|