prreviewbuddy 0.46.0 → 0.48.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/CHANGELOG.md +50 -0
- package/dist/{delete_review-lmVdoybL.js → delete_review-D52wkt66.js} +133 -9
- package/dist/main.js +1 -1
- package/dist/server.js +425 -146
- package/package.json +1 -1
- package/static/chunk-BeqgAIrQ.js +1 -1
- package/static/chunk-DaUgQrSg.js +1 -0
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +201 -115
- package/static/workspace.js +2 -2
- package/static/chunk-CmPWaXhU.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as createPrState, An as
|
|
1
|
+
import { $t as mutateWorkspace, A as createPrState, An as describeAuthorship, At as allJobs, B as toneOf, Bt as purposeOf, C as readEvents, Cn as doneVerb, Ct as forgeFor, D as announceJob, Dn as questionsOutstanding, Dt as checkReview, E as telemetryUploadConsent, En as issuesOutstanding, Et as checkCodeFreshness, F as markAllRead, Ft as saveJob, G as extractJsonText, Gn as agentFor, Gt as groupByLineage, H as withUsageRecorded, Hn as AGENT_IDS, I as markRead, In as resolveModel, J as readMarker, Jn as AgentUnavailableError, Jt as latestKissRun, K as ReviewBeingDeletedError, Kt as indexKeptIds, L as pruneNotifications, Ln as clearAgentPreference, Lt as isLiveJob, M as eventName, Mt as fail, N as headline, Nn as isCommitReview, Nt as isTerminal, O as branchOf, On as reviewerDispositions, Ot as followedBranch, P as listNotifications, Pn as git, Pt as loadJob, Q as compactQueue, R as readNotification, Rn as readAgentPreference, Rt as phasesFor, Sn as readQueue, T as recordTelemetryUploadConsent, Tn as isQuestionOutstanding, Tt as fillFileUrlTemplate, U as EXPLAIN_SIMPLY_PROMPT, Ut as isClaimed, V as unreadCount$1, Vn as STORE_ROOT, W as asString, Wt as followedRefName, Xt as lineagePosition, Y as removeWorktree, Z as relativeTime$1, Zt as loadWorkspace, _ as agentEnvOf, _n as dequeueOnComplete, _t as parseWorkspacePort, a as wasBlocked, bn as queueRemove, bt as writePortPreference, c as RefreshUnavailableError, cn as workspaceStamps, d as refreshPrContext, dn as isLifecycle, dt as BUILD_VERSION, et as readIndexToken, f as isUnchanged, fn as lifecycleAction, gn as sectionOf, gt as MIN_WORKSPACE_PORT, h as liveUpdateFor, hn as parseLifecycle, ht as MAX_WORKSPACE_PORT, i as liveJobsFor, it as readServerRecord, j as createPrUpdate, jt as endedAt, k as subjectOf, kn as processDiscussion, kt as reviewedShaOf, l as mergeUpdateInto, ln as LIFECYCLES, m as UpdateAlreadyRunningError, mn as offersLifecycle, mt as DEFAULT_WORKSPACE_PORT, n as deleteReview, nn as recentWorkspaces, nt as clearServerRecord, o as discardJob, on as touchWorkspace, ot as serverEntryExists, p as askCheckout, pn as lifecycleChip, pt as feedbackUrl, q as MANAGED_ROOT, qn as AgentCancelledError, qt as indexReviewGroups, r as lineageIds, rn as reviewedCommit, s as FETCH_TIMEOUT_MS, sn as workspaceRevision, st as spawnServer, t as deleteLineage, tn as previousKissResult, u as updateReview, un as activityLabel, ut as writeServerRecord, vn as queueAdd, vt as resolveWorkspacePort, w as record, wn as isIssueOutstanding, wt as remoteHost, x as runJob, xn as queueStamp, xt as claimRequestUrl, y as startKissJob, yn as queueAddMany, yt as statedWorkspacePort, z as removeNotification, zn as writeAgentPreference, zt as progressSteps } from "./delete_review-D52wkt66.js";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -4550,6 +4550,46 @@ function renderPrState(state, besideNumber = false) {
|
|
|
4550
4550
|
return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : besideNumber ? "Closed" : "PR closed"}</span>`;
|
|
4551
4551
|
}
|
|
4552
4552
|
//#endregion
|
|
4553
|
+
//#region ../../packages/review-harness/src/workspace/theme.ts
|
|
4554
|
+
/**
|
|
4555
|
+
* Which palette the workspace's furniture is drawn in: the reviewer's device, or a fixed choice.
|
|
4556
|
+
*
|
|
4557
|
+
* One axis with a registry rather than a flag per value, so the stored type, the settings page's
|
|
4558
|
+
* radios and the attribute on `<html>` all derive from the same list and cannot disagree about
|
|
4559
|
+
* what the choices are.
|
|
4560
|
+
*
|
|
4561
|
+
* Pure on purpose: the settings page's script imports this, and the client bundle must not reach
|
|
4562
|
+
* anything that pulls in a node builtin.
|
|
4563
|
+
*
|
|
4564
|
+
* The attribute is the whole mechanism. shell.css sets `color-scheme` from it, and every shell
|
|
4565
|
+
* token is a `light-dark()` pair that the browser resolves from `color-scheme`, so System follows
|
|
4566
|
+
* the device live, with no listener and no reload.
|
|
4567
|
+
*/
|
|
4568
|
+
var THEMES = [
|
|
4569
|
+
{
|
|
4570
|
+
id: "system",
|
|
4571
|
+
label: "System",
|
|
4572
|
+
help: "Follow your device setting"
|
|
4573
|
+
},
|
|
4574
|
+
{
|
|
4575
|
+
id: "light",
|
|
4576
|
+
label: "Light",
|
|
4577
|
+
help: "Always use light mode"
|
|
4578
|
+
},
|
|
4579
|
+
{
|
|
4580
|
+
id: "dark",
|
|
4581
|
+
label: "Dark",
|
|
4582
|
+
help: "Always use dark mode"
|
|
4583
|
+
}
|
|
4584
|
+
];
|
|
4585
|
+
function isTheme(value) {
|
|
4586
|
+
return THEMES.some((theme) => theme.id === value);
|
|
4587
|
+
}
|
|
4588
|
+
/** The `<html>` attribute, leading space included, so a root reads `<html lang="en"${…}>`. */
|
|
4589
|
+
function themeAttribute(theme) {
|
|
4590
|
+
return ` data-theme="${theme ?? "system"}"`;
|
|
4591
|
+
}
|
|
4592
|
+
//#endregion
|
|
4553
4593
|
//#region ../../packages/review-harness/src/workspace/page.ts
|
|
4554
4594
|
function renderWorkspace(view) {
|
|
4555
4595
|
const { session, changeSet } = view;
|
|
@@ -4569,7 +4609,7 @@ function renderWorkspace(view) {
|
|
|
4569
4609
|
*/
|
|
4570
4610
|
const freshness = result ? view.freshness : void 0;
|
|
4571
4611
|
return `<!doctype html>
|
|
4572
|
-
<html lang="en">
|
|
4612
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
4573
4613
|
<head>
|
|
4574
4614
|
<meta charset="utf-8">
|
|
4575
4615
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -5718,7 +5758,8 @@ function normalise$1(entries) {
|
|
|
5718
5758
|
if (typeof raw.observedAt !== "number") continue;
|
|
5719
5759
|
states[key] = {
|
|
5720
5760
|
state: raw.state,
|
|
5721
|
-
observedAt: raw.observedAt
|
|
5761
|
+
observedAt: raw.observedAt,
|
|
5762
|
+
...typeof raw.changedAt === "number" ? { changedAt: raw.changedAt } : {}
|
|
5722
5763
|
};
|
|
5723
5764
|
}
|
|
5724
5765
|
return states;
|
|
@@ -5834,6 +5875,7 @@ function normalise(entries) {
|
|
|
5834
5875
|
if (typeof raw.polledAt === "number") entry.polledAt = raw.polledAt;
|
|
5835
5876
|
if (typeof raw.observedSha === "string") entry.observedSha = raw.observedSha;
|
|
5836
5877
|
if (typeof raw.observedAt === "number") entry.observedAt = raw.observedAt;
|
|
5878
|
+
if (typeof raw.changedAt === "number") entry.changedAt = raw.changedAt;
|
|
5837
5879
|
if (typeof raw.notifiedSha === "string") entry.notifiedSha = raw.notifiedSha;
|
|
5838
5880
|
if (typeof raw.notificationId === "string") entry.notificationId = raw.notificationId;
|
|
5839
5881
|
if (typeof raw.failures === "number") entry.failures = raw.failures;
|
|
@@ -6172,10 +6214,12 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
|
|
|
6172
6214
|
}
|
|
6173
6215
|
if (observed.kind === "unresolved") continue;
|
|
6174
6216
|
if (observed.prState !== void 0 && !isBranchCandidate(candidate)) recordPrState(candidate, key, observed.sha, observed.prState, prStates, now);
|
|
6217
|
+
const lastSeen = state[key]?.observedSha;
|
|
6175
6218
|
state[key] = {
|
|
6176
6219
|
...state[key],
|
|
6177
6220
|
observedSha: observed.sha,
|
|
6178
6221
|
observedAt: seenAt,
|
|
6222
|
+
...lastSeen === void 0 || observed.sha === lastSeen ? {} : { changedAt: seenAt },
|
|
6179
6223
|
failures: 0
|
|
6180
6224
|
};
|
|
6181
6225
|
if (observed.sha === candidate.analysedSha) {
|
|
@@ -6220,7 +6264,8 @@ function prunedPrStates(prStates, deps) {
|
|
|
6220
6264
|
*/
|
|
6221
6265
|
function recordPrState(candidate, key, headSha, observed, prStates, now) {
|
|
6222
6266
|
if (observed === null) return;
|
|
6223
|
-
|
|
6267
|
+
const previous = prStates[key]?.state;
|
|
6268
|
+
if (previous === "open" && observed !== "open") {
|
|
6224
6269
|
const id = `pr-${observed}-${candidate.reviewId}-${headSha}`;
|
|
6225
6270
|
if (createPrState({
|
|
6226
6271
|
id,
|
|
@@ -6232,9 +6277,11 @@ function recordPrState(candidate, key, headSha, observed, prStates, now) {
|
|
|
6232
6277
|
headSha
|
|
6233
6278
|
}, now) === null && readNotification(id) === null) return;
|
|
6234
6279
|
}
|
|
6280
|
+
const changedAt = previous !== void 0 && previous !== observed ? now : prStates[key]?.changedAt;
|
|
6235
6281
|
prStates[key] = {
|
|
6236
6282
|
state: observed,
|
|
6237
|
-
observedAt: now
|
|
6283
|
+
observedAt: now,
|
|
6284
|
+
...changedAt === void 0 ? {} : { changedAt }
|
|
6238
6285
|
};
|
|
6239
6286
|
}
|
|
6240
6287
|
async function observeRequest(candidate, deps, unreachable) {
|
|
@@ -6400,6 +6447,12 @@ async function announce(candidate, key, observed, state, deps, now) {
|
|
|
6400
6447
|
* and a review with no `completedAt` is one they have not finished. Anything outside those is a
|
|
6401
6448
|
* review they are done with, and polling it would spend a request on an answer nobody wants.
|
|
6402
6449
|
*
|
|
6450
|
+
* **Waiting and Done are watched whatever else is true of them.** Parking a review takes it out of
|
|
6451
|
+
* the queue and is usually done after marking it reviewed, so without this a Waiting review would
|
|
6452
|
+
* fall outside all three sets at the moment it starts waiting for a push, and the push that should
|
|
6453
|
+
* bring it back to Active would never be seen. Done is watched for "Changed since done": the reviewer
|
|
6454
|
+
* asked not to be interrupted, not to be kept uninformed.
|
|
6455
|
+
*
|
|
6403
6456
|
* The one exclusion is structural rather than editorial: a commit review is of one fixed commit
|
|
6404
6457
|
* that cannot move, and `freshness.ts` calls that state `fixed` for the same reason. A review with
|
|
6405
6458
|
* no pull request used to be excluded too, having nothing to ask a forge about. It is watched
|
|
@@ -6407,7 +6460,7 @@ async function announce(candidate, key, observed, state, deps, now) {
|
|
|
6407
6460
|
*/
|
|
6408
6461
|
function isWatchable(workspace, queued) {
|
|
6409
6462
|
if (isCommitReview(workspace.changeSet)) return false;
|
|
6410
|
-
return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0;
|
|
6463
|
+
return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0 || parseLifecycle(workspace.lifecycle) !== void 0;
|
|
6411
6464
|
}
|
|
6412
6465
|
/** The request a review is attached to, by URL where there is one and by number otherwise. */
|
|
6413
6466
|
function requestOf(workspace) {
|
|
@@ -6498,6 +6551,50 @@ function prStatesByRequest(states = readPrStates()) {
|
|
|
6498
6551
|
return new Map(Object.entries(states).map(([key, entry]) => [key, entry.state]));
|
|
6499
6552
|
}
|
|
6500
6553
|
/**
|
|
6554
|
+
* What last happened to each branch's change, by the id of its newest review: the list's order and
|
|
6555
|
+
* the time on each card.
|
|
6556
|
+
*
|
|
6557
|
+
* The latest of three things, each of which is the change moving rather than anybody looking at it:
|
|
6558
|
+
* the newest analysis being made, its head moving, and its pull request changing state. Read from
|
|
6559
|
+
* the two `changedAt`s, never the `observedAt`s, which every poll rewrites.
|
|
6560
|
+
*
|
|
6561
|
+
* A watch entry follows the newest analysis of its request, so it is looked for across the whole
|
|
6562
|
+
* history, the same way `moved` is: an entry still filed against an older analysis is the same
|
|
6563
|
+
* change. A head move or state change from before the newest analysis is already in it, so only a
|
|
6564
|
+
* later one counts.
|
|
6565
|
+
*/
|
|
6566
|
+
function reviewActivity(groups, watch = readWatch(), prStates = readPrStates()) {
|
|
6567
|
+
const headChanges = /* @__PURE__ */ new Map();
|
|
6568
|
+
for (const entry of Object.values(watch)) {
|
|
6569
|
+
if (entry.reviewId === void 0 || entry.changedAt === void 0) continue;
|
|
6570
|
+
headChanges.set(entry.reviewId, Math.max(entry.changedAt, headChanges.get(entry.reviewId) ?? 0));
|
|
6571
|
+
}
|
|
6572
|
+
const activity = /* @__PURE__ */ new Map();
|
|
6573
|
+
for (const group of groups) {
|
|
6574
|
+
const { latest } = group;
|
|
6575
|
+
let current = {
|
|
6576
|
+
at: latest.createdAt,
|
|
6577
|
+
kind: "created"
|
|
6578
|
+
};
|
|
6579
|
+
for (const review of group.history) {
|
|
6580
|
+
const at = headChanges.get(review.id);
|
|
6581
|
+
if (at !== void 0 && at > current.at) current = {
|
|
6582
|
+
at,
|
|
6583
|
+
kind: "updated"
|
|
6584
|
+
};
|
|
6585
|
+
}
|
|
6586
|
+
if (latest.prNumber !== void 0) {
|
|
6587
|
+
const entry = prStates[requestKey(latest.repoPath, latest.prNumber)];
|
|
6588
|
+
if (entry?.changedAt !== void 0 && entry.changedAt > current.at) current = {
|
|
6589
|
+
at: entry.changedAt,
|
|
6590
|
+
kind: entry.state === "open" ? "reopened" : entry.state
|
|
6591
|
+
};
|
|
6592
|
+
}
|
|
6593
|
+
activity.set(latest.id, current);
|
|
6594
|
+
}
|
|
6595
|
+
return activity;
|
|
6596
|
+
}
|
|
6597
|
+
/**
|
|
6501
6598
|
* One review's pull request state, for its page, filed under the same key the list looks up. Null
|
|
6502
6599
|
* for a review with no request or one never observed: unknown, not open. Only ever what the watcher
|
|
6503
6600
|
* last saw; nothing here reaches the forge.
|
|
@@ -6561,6 +6658,23 @@ function watchNow(scope, makeDeps = defaultDeps) {
|
|
|
6561
6658
|
});
|
|
6562
6659
|
}
|
|
6563
6660
|
/**
|
|
6661
|
+
* A first look at every one of these reviews, however many there are.
|
|
6662
|
+
*
|
|
6663
|
+
* For parking a selection as Waiting. `watchNow` keeps the request budget of an ordinary cycle, so
|
|
6664
|
+
* handed thirty reviews it would look at twenty and leave ten to the timer, and a push to one of
|
|
6665
|
+
* those before its turn would be taken as its first observation and never bring it back. So the
|
|
6666
|
+
* selection goes through in batches of that budget, one scoped cycle after another: every review is
|
|
6667
|
+
* looked at, and no single cycle asks more of the forge than an ordinary one does. The whole
|
|
6668
|
+
* selection is the reviewer's own act, which is what bounds how many batches there can be.
|
|
6669
|
+
*/
|
|
6670
|
+
async function watchAllNow(reviewIds, makeDeps = defaultDeps) {
|
|
6671
|
+
const ids = [...reviewIds];
|
|
6672
|
+
for (let start = 0; start < ids.length; start += 20) {
|
|
6673
|
+
const batch = new Set(ids.slice(start, start + 20));
|
|
6674
|
+
await watchNow((candidate) => batch.has(candidate.reviewId), makeDeps);
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
/**
|
|
6564
6678
|
* Run the pass on a timer, and return the stop.
|
|
6565
6679
|
*
|
|
6566
6680
|
* The same shape as `startSweeping`, and tied into `cli/server.ts` the same way, so a restart
|
|
@@ -6600,14 +6714,26 @@ var FETCH_SELECTED_LABEL = "Fetch selected";
|
|
|
6600
6714
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
6601
6715
|
function renderReviewsPage(view) {
|
|
6602
6716
|
const now = view.now ?? Date.now();
|
|
6603
|
-
const
|
|
6717
|
+
const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
|
|
6718
|
+
at: group.latest.createdAt,
|
|
6719
|
+
kind: "created"
|
|
6720
|
+
};
|
|
6721
|
+
const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || activityOf(b).at - activityOf(a).at);
|
|
6722
|
+
const sections = {
|
|
6723
|
+
active: [],
|
|
6724
|
+
waiting: [],
|
|
6725
|
+
done: []
|
|
6726
|
+
};
|
|
6727
|
+
for (const group of ordered) sections[sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
|
|
6728
|
+
const rank = new Map(ordered.map((group, index) => [group, index]));
|
|
6729
|
+
const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates))).join("");
|
|
6604
6730
|
const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
|
|
6605
6731
|
const queue = view.queue ?? [];
|
|
6606
6732
|
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
6607
6733
|
const continues = continueTarget(queue);
|
|
6608
6734
|
const front = continues === null ? void 0 : findReview(view.groups, continues);
|
|
6609
6735
|
return `<!doctype html>
|
|
6610
|
-
<html lang="en">
|
|
6736
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
6611
6737
|
<head>
|
|
6612
6738
|
<meta charset="utf-8">
|
|
6613
6739
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -6634,13 +6760,15 @@ ${renderWorkspaceRail({
|
|
|
6634
6760
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
6635
6761
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
6636
6762
|
</div>`}
|
|
6637
|
-
<div class="prb-cards" data-cards>
|
|
6638
|
-
${
|
|
6763
|
+
<div class="prb-cards" data-cards="active">
|
|
6764
|
+
${renderCards(sections.active)}
|
|
6639
6765
|
</div>
|
|
6766
|
+
${renderLoadMore("active")}
|
|
6640
6767
|
${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
|
|
6641
|
-
${
|
|
6768
|
+
${renderSection("waiting", "Waiting", sections.waiting, true, renderCards)}
|
|
6769
|
+
${renderSection("done", "Done", sections.done, false, renderCards)}
|
|
6642
6770
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
6643
|
-
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
|
|
6771
|
+
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews, Done reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened or marked them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
|
|
6644
6772
|
</main>
|
|
6645
6773
|
</div>
|
|
6646
6774
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
@@ -6721,22 +6849,31 @@ function findReview(groups, id) {
|
|
|
6721
6849
|
for (const group of groups) for (const review of group.history) if (review.id === id) return review;
|
|
6722
6850
|
}
|
|
6723
6851
|
/**
|
|
6724
|
-
*
|
|
6852
|
+
* One section's Load more, which the browser shows once that section holds more than it is showing.
|
|
6725
6853
|
*
|
|
6726
|
-
*
|
|
6727
|
-
*
|
|
6728
|
-
*
|
|
6729
|
-
|
|
6854
|
+
* One per section rather than one pager for the page, so revealing older Done reviews never costs
|
|
6855
|
+
* the reviewer their place in Active. Hidden until the client has counted, so a browser with no
|
|
6856
|
+
* JavaScript gets every card and no control that does nothing.
|
|
6857
|
+
*/
|
|
6858
|
+
function renderLoadMore(section) {
|
|
6859
|
+
return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
|
|
6860
|
+
}
|
|
6861
|
+
/**
|
|
6862
|
+
* Waiting or Done, as a disclosure under the working list.
|
|
6730
6863
|
*
|
|
6731
|
-
*
|
|
6732
|
-
*
|
|
6864
|
+
* Active has no heading: it is what the page has always been, and the count line above it already
|
|
6865
|
+
* says what it holds. Waiting starts open, because it is work that will come back; Done starts
|
|
6866
|
+
* closed, because it is the archive. Rendered empty and hidden rather than left out, so a card the
|
|
6867
|
+
* browser moves here after a mark has somewhere to go.
|
|
6733
6868
|
*/
|
|
6734
|
-
function
|
|
6735
|
-
return `<
|
|
6736
|
-
<
|
|
6737
|
-
<
|
|
6738
|
-
|
|
6739
|
-
|
|
6869
|
+
function renderSection(section, title, groups, open, renderCards) {
|
|
6870
|
+
return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
|
|
6871
|
+
<summary class="prb-index-section-head">${title} · <span data-section-count>${groups.length}</span></summary>
|
|
6872
|
+
<div class="prb-cards" data-cards="${section}">
|
|
6873
|
+
${renderCards(groups)}
|
|
6874
|
+
</div>
|
|
6875
|
+
${renderLoadMore(section)}
|
|
6876
|
+
</details>`;
|
|
6740
6877
|
}
|
|
6741
6878
|
/**
|
|
6742
6879
|
* The bulk bar, with its counts left out.
|
|
@@ -6761,6 +6898,7 @@ function renderBulkBar() {
|
|
|
6761
6898
|
<button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
|
|
6762
6899
|
<button type="button" class="prb-bulk-button" data-bulk-mark="complete">Mark reviewed</button>
|
|
6763
6900
|
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">Mark in progress</button>
|
|
6901
|
+
${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
|
|
6764
6902
|
<button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
|
|
6765
6903
|
<button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
|
|
6766
6904
|
</div>
|
|
@@ -6794,20 +6932,22 @@ function renderBulkBar() {
|
|
|
6794
6932
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
6795
6933
|
* than a rebuild of the list.
|
|
6796
6934
|
*/
|
|
6797
|
-
function renderCard(group, now, analysis, queued, moved, prState) {
|
|
6935
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
|
|
6798
6936
|
const review = group.latest;
|
|
6799
6937
|
const status = statusOf(review);
|
|
6938
|
+
const chip = lifecycleChip(review.lifecycle, activity);
|
|
6800
6939
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
6801
6940
|
const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
|
|
6802
|
-
return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
|
|
6941
|
+
return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""} data-activity-at="${activity.at}" data-activity-kind="${activity.kind}" data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
|
|
6803
6942
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
6804
6943
|
<button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unsave" : "Save"} this review">${review.pinned ? "★" : "☆"}</button>
|
|
6805
|
-
${renderCardMenu(group, followed, queued !== void 0, status)}
|
|
6944
|
+
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
6806
6945
|
<a class="prb-card-link" href="${escapeHtml$1(href)}">
|
|
6807
6946
|
<div class="prb-card-head">
|
|
6808
6947
|
<h2>${escapeHtml$1(review.title)}</h2>
|
|
6809
6948
|
${renderStatus(status)}
|
|
6810
6949
|
${renderPrState(prState)}
|
|
6950
|
+
${`<span class="prb-card-lifecycle" data-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>`}
|
|
6811
6951
|
</div>
|
|
6812
6952
|
${review.summary ? `<p class="prb-card-summary">${renderInlineText(review.summary)}</p>` : ""}
|
|
6813
6953
|
<p class="prb-card-where">
|
|
@@ -6824,7 +6964,7 @@ function renderCard(group, now, analysis, queued, moved, prState) {
|
|
|
6824
6964
|
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
6825
6965
|
${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
|
|
6826
6966
|
${renderAnalysisState(analysis)}
|
|
6827
|
-
<span class="prb-card-when"><time datetime="${new Date(
|
|
6967
|
+
<span class="prb-card-when"><time datetime="${new Date(activity.at).toISOString()}" title="${escapeHtml$1(new Date(activity.at).toLocaleString())}">${escapeHtml$1(`${activityLabel(activity.kind)} ${relativeTime(activity.at, now)}`)}</time></span>
|
|
6828
6968
|
</p>
|
|
6829
6969
|
</a>
|
|
6830
6970
|
${renderHistory(group, now)}
|
|
@@ -6847,9 +6987,13 @@ function renderCard(group, now, analysis, queued, moved, prState) {
|
|
|
6847
6987
|
*
|
|
6848
6988
|
* The rows underneath carry the per-review menus, and they only exist once there is more than one.
|
|
6849
6989
|
*/
|
|
6850
|
-
function renderCardMenu(group, followed, queued, status) {
|
|
6990
|
+
function renderCardMenu(group, followed, queued, status, activity) {
|
|
6851
6991
|
const count = group.history.length;
|
|
6852
6992
|
const mark = status === "complete" ? "in-progress" : "complete";
|
|
6993
|
+
const lifecycle = {
|
|
6994
|
+
stored: group.latest.lifecycle,
|
|
6995
|
+
activity
|
|
6996
|
+
};
|
|
6853
6997
|
if (count < 2) return renderMenu({
|
|
6854
6998
|
id: group.latest.id,
|
|
6855
6999
|
scope: "review",
|
|
@@ -6858,7 +7002,8 @@ function renderCardMenu(group, followed, queued, status) {
|
|
|
6858
7002
|
question: "Delete this review?",
|
|
6859
7003
|
copy: "Copy review ID",
|
|
6860
7004
|
queue: queued ? "remove" : "add",
|
|
6861
|
-
mark
|
|
7005
|
+
mark,
|
|
7006
|
+
lifecycle
|
|
6862
7007
|
});
|
|
6863
7008
|
const all = `all ${count} reviews following ${followed}`;
|
|
6864
7009
|
return renderMenu({
|
|
@@ -6869,6 +7014,7 @@ function renderCardMenu(group, followed, queued, status) {
|
|
|
6869
7014
|
question: `Delete ${all}?`,
|
|
6870
7015
|
queue: queued ? "remove" : "add",
|
|
6871
7016
|
mark,
|
|
7017
|
+
lifecycle,
|
|
6872
7018
|
copy: "Copy latest review ID"
|
|
6873
7019
|
});
|
|
6874
7020
|
}
|
|
@@ -6889,11 +7035,13 @@ function renderMenu(item) {
|
|
|
6889
7035
|
const id = escapeHtml$1(item.id);
|
|
6890
7036
|
const markItem = item.mark === void 0 ? "" : `<button type="button" class="prb-menu-item" data-mark-status="${item.mark}" data-mark-id="${id}">${markItemLabel(item.mark)}</button>`;
|
|
6891
7037
|
const queueItem = item.queue === void 0 ? "" : `<button type="button" class="prb-menu-item" data-queue-action="${item.queue}" data-queue-id="${id}">${item.queue === "remove" ? "Remove from review queue" : "Add to review queue"}</button>`;
|
|
7038
|
+
const lifecycleItems = item.lifecycle === void 0 ? "" : LIFECYCLES.map(({ id: target }) => `<button type="button" class="prb-menu-item" data-lifecycle-set="${target}" data-lifecycle-id="${id}"${offersLifecycle(target, item.lifecycle.stored, item.lifecycle.activity) ? "" : " hidden"}>${escapeHtml$1(lifecycleAction(target))}</button>`).join("\n ");
|
|
6892
7039
|
return `<div class="prb-menu" data-menu>
|
|
6893
7040
|
<button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(item.label)}">⋯</button>
|
|
6894
7041
|
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
6895
7042
|
<div data-menu-items>
|
|
6896
7043
|
${markItem}
|
|
7044
|
+
${lifecycleItems}
|
|
6897
7045
|
${queueItem}
|
|
6898
7046
|
<button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
|
|
6899
7047
|
<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
|
|
@@ -7040,13 +7188,6 @@ function renderRisk(risk) {
|
|
|
7040
7188
|
if (risk === "unknown") return "";
|
|
7041
7189
|
return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
|
|
7042
7190
|
}
|
|
7043
|
-
/** Last opened where that is known, created otherwise. */
|
|
7044
|
-
function touchedAt(review) {
|
|
7045
|
-
return review.lastViewedAt ?? review.createdAt;
|
|
7046
|
-
}
|
|
7047
|
-
function whenLabel(review, now) {
|
|
7048
|
-
return review.lastViewedAt ? `Opened ${relativeTime(review.lastViewedAt, now)}` : `Created ${relativeTime(review.createdAt, now)}`;
|
|
7049
|
-
}
|
|
7050
7191
|
/**
|
|
7051
7192
|
* Everything the search field matches, flattened into one string on the card.
|
|
7052
7193
|
*
|
|
@@ -7136,6 +7277,115 @@ function escapeHtml(value) {
|
|
|
7136
7277
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7137
7278
|
}
|
|
7138
7279
|
//#endregion
|
|
7280
|
+
//#region ../../packages/review-harness/src/workspace/preferences.ts
|
|
7281
|
+
/**
|
|
7282
|
+
* Workspace preferences that belong to the reviewer rather than to a review.
|
|
7283
|
+
*
|
|
7284
|
+
* Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
|
|
7285
|
+
* origin and this server took whatever port the OS offered on each start, so a restart would
|
|
7286
|
+
* silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
|
|
7287
|
+
* fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
|
|
7288
|
+
* making on its own merits rather than in passing, and there is a second reason it stays for now:
|
|
7289
|
+
* `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
|
|
7290
|
+
* it under them the first time they save, which is why this is not simply a key in there.
|
|
7291
|
+
*
|
|
7292
|
+
* Everything here is best-effort. A workspace that will not open because a preferences file is
|
|
7293
|
+
* unreadable would be a preference costing more than it is worth.
|
|
7294
|
+
*/
|
|
7295
|
+
var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
|
|
7296
|
+
var MAX_ASK_WIDTH = 1200;
|
|
7297
|
+
/** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
|
|
7298
|
+
var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
|
|
7299
|
+
function readPreferences() {
|
|
7300
|
+
try {
|
|
7301
|
+
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
7302
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
7303
|
+
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme } = parsed;
|
|
7304
|
+
return {
|
|
7305
|
+
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
7306
|
+
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
7307
|
+
...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
|
|
7308
|
+
...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
|
|
7309
|
+
...isTheme(theme) ? { theme } : {}
|
|
7310
|
+
};
|
|
7311
|
+
} catch {
|
|
7312
|
+
return {};
|
|
7313
|
+
}
|
|
7314
|
+
}
|
|
7315
|
+
function writeAskWidth(width) {
|
|
7316
|
+
if (!Number.isFinite(width)) return;
|
|
7317
|
+
try {
|
|
7318
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7319
|
+
const next = {
|
|
7320
|
+
...readPreferences(),
|
|
7321
|
+
askWidth: clampAskWidth(width)
|
|
7322
|
+
};
|
|
7323
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
7324
|
+
} catch {}
|
|
7325
|
+
}
|
|
7326
|
+
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
7327
|
+
function writePromptsCollapsed(collapsed) {
|
|
7328
|
+
try {
|
|
7329
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7330
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7331
|
+
...readPreferences(),
|
|
7332
|
+
promptsCollapsed: collapsed
|
|
7333
|
+
}, null, 2)}\n`);
|
|
7334
|
+
} catch {}
|
|
7335
|
+
}
|
|
7336
|
+
/** Remember which words the change map is read in. Read-modify-write, like every other field here. */
|
|
7337
|
+
function writeChangeMapRegister(register) {
|
|
7338
|
+
try {
|
|
7339
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7340
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7341
|
+
...readPreferences(),
|
|
7342
|
+
changeMapRegister: register
|
|
7343
|
+
}, null, 2)}\n`);
|
|
7344
|
+
} catch {}
|
|
7345
|
+
}
|
|
7346
|
+
/** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
|
|
7347
|
+
function writeTheme(theme) {
|
|
7348
|
+
try {
|
|
7349
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7350
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7351
|
+
...readPreferences(),
|
|
7352
|
+
theme
|
|
7353
|
+
}, null, 2)}\n`);
|
|
7354
|
+
} catch {}
|
|
7355
|
+
}
|
|
7356
|
+
/** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
|
|
7357
|
+
function dismissNotice(key) {
|
|
7358
|
+
if (!NOTICE_KEY.test(key)) return false;
|
|
7359
|
+
try {
|
|
7360
|
+
const current = readPreferences();
|
|
7361
|
+
const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
|
|
7362
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7363
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7364
|
+
...current,
|
|
7365
|
+
dismissedNotices
|
|
7366
|
+
}, null, 2)}\n`);
|
|
7367
|
+
return true;
|
|
7368
|
+
} catch {
|
|
7369
|
+
return false;
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
/** Forget a stated width, so the stylesheet's proportional default applies again. */
|
|
7373
|
+
function clearAskWidth() {
|
|
7374
|
+
try {
|
|
7375
|
+
const { askWidth: _dropped, ...rest } = readPreferences();
|
|
7376
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7377
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
|
|
7378
|
+
} catch {}
|
|
7379
|
+
}
|
|
7380
|
+
/**
|
|
7381
|
+
* Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
|
|
7382
|
+
* the page enforces because it is the only party that knows how wide the window is — this is the
|
|
7383
|
+
* far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
|
|
7384
|
+
*/
|
|
7385
|
+
function clampAskWidth(width) {
|
|
7386
|
+
return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
|
|
7387
|
+
}
|
|
7388
|
+
//#endregion
|
|
7139
7389
|
//#region ../../packages/review-harness/src/workspace/settings.ts
|
|
7140
7390
|
/**
|
|
7141
7391
|
* The settings the workspace can show and change, as one object rather than as three files.
|
|
@@ -7167,7 +7417,8 @@ function readSettings() {
|
|
|
7167
7417
|
},
|
|
7168
7418
|
agent: readAgentPreference(),
|
|
7169
7419
|
agents: AGENT_IDS,
|
|
7170
|
-
usageCounts: telemetryUploadConsent()
|
|
7420
|
+
usageCounts: telemetryUploadConsent(),
|
|
7421
|
+
theme: readPreferences().theme ?? "system"
|
|
7171
7422
|
};
|
|
7172
7423
|
}
|
|
7173
7424
|
/** True when what the reader is looking at is served on a port they have since changed. */
|
|
@@ -7194,10 +7445,19 @@ function saveSettings(change, minPort, maxPort) {
|
|
|
7194
7445
|
settings: readSettings(),
|
|
7195
7446
|
error: `There is no agent called \`${change.agent}\`.`
|
|
7196
7447
|
};
|
|
7448
|
+
if (change.theme !== void 0 && !isTheme(change.theme)) {
|
|
7449
|
+
const labels = THEMES.map((theme) => theme.label);
|
|
7450
|
+
const choices = `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`;
|
|
7451
|
+
return {
|
|
7452
|
+
settings: readSettings(),
|
|
7453
|
+
error: `There is no theme called \`${change.theme}\`. Choose ${choices}.`
|
|
7454
|
+
};
|
|
7455
|
+
}
|
|
7197
7456
|
if (port !== null) writePortPreference(port);
|
|
7198
7457
|
if (change.agent === "") clearAgentPreference();
|
|
7199
7458
|
else if (change.agent !== void 0) writeAgentPreference(change.agent);
|
|
7200
7459
|
if (change.usageCounts !== void 0) recordTelemetryUploadConsent(change.usageCounts);
|
|
7460
|
+
if (change.theme !== void 0 && isTheme(change.theme)) writeTheme(change.theme);
|
|
7201
7461
|
return {
|
|
7202
7462
|
settings: readSettings(),
|
|
7203
7463
|
error: null
|
|
@@ -7209,7 +7469,7 @@ function renderSettingsPage(view) {
|
|
|
7209
7469
|
const { settings } = view;
|
|
7210
7470
|
const address = `http://localhost:${settings.port.configured}`;
|
|
7211
7471
|
return `<!doctype html>
|
|
7212
|
-
<html lang="en">
|
|
7472
|
+
<html lang="en"${themeAttribute(settings.theme)}>
|
|
7213
7473
|
<head>
|
|
7214
7474
|
<meta charset="utf-8">
|
|
7215
7475
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -7231,6 +7491,7 @@ ${renderWorkspaceRail({
|
|
|
7231
7491
|
unread: view.unreadNotifications ?? 0
|
|
7232
7492
|
})}
|
|
7233
7493
|
<main class="prb-index prb-settings">
|
|
7494
|
+
${renderAppearanceSection(settings)}
|
|
7234
7495
|
${renderWorkspaceSection(settings, address)}
|
|
7235
7496
|
${renderReviewsSection(settings)}
|
|
7236
7497
|
${renderPrivacySection(settings)}
|
|
@@ -7242,7 +7503,8 @@ ${renderWorkspaceRail({
|
|
|
7242
7503
|
|
|
7243
7504
|
<p class="prb-index-note">These are kept in <code>~/.prreviewbuddy/config.json</code>, which you can
|
|
7244
7505
|
edit by hand, and they are the same settings <code>prreviewbuddy config</code> reads and writes.
|
|
7245
|
-
Run <code>prreviewbuddy config path</code> to be told where that file is
|
|
7506
|
+
Run <code>prreviewbuddy config path</code> to be told where that file is. Appearance is kept with
|
|
7507
|
+
the workspace's other display choices in <code>~/.prreviewbuddy/ui.json</code>.</p>
|
|
7246
7508
|
|
|
7247
7509
|
${renderServerSection()}
|
|
7248
7510
|
</main>
|
|
@@ -7310,6 +7572,23 @@ function renderWorkspaceSection(settings, address) {
|
|
|
7310
7572
|
working tomorrow.${moved ? ` <strong>You are reading this on port ${settings.port.current}.</strong> The port above is used the next time the workspace starts.` : ""}</p>
|
|
7311
7573
|
</section>`;
|
|
7312
7574
|
}
|
|
7575
|
+
/**
|
|
7576
|
+
* The theme, as three radios over the registry in theme.ts.
|
|
7577
|
+
*
|
|
7578
|
+
* Choosing one repaints this page at once (see `client/settings.ts`), so the reader can see what
|
|
7579
|
+
* they are picking; Save is still what keeps it, like every other field here.
|
|
7580
|
+
*/
|
|
7581
|
+
function renderAppearanceSection(settings) {
|
|
7582
|
+
return `<section class="prb-settings-section">
|
|
7583
|
+
<h2>Appearance</h2>
|
|
7584
|
+
<div class="prb-field prb-settings-field" role="group" aria-labelledby="prb-theme-label">
|
|
7585
|
+
<span id="prb-theme-label">Theme</span>
|
|
7586
|
+
<div class="prb-settings-choice">
|
|
7587
|
+
${THEMES.map((theme) => `<label><input type="radio" name="prb-theme" value="${theme.id}" data-settings-theme${settings.theme === theme.id ? " checked" : ""}> ${theme.label} <small class="prb-settings-choice-help">${theme.help}</small></label>`).join("\n ")}
|
|
7588
|
+
</div>
|
|
7589
|
+
</div>
|
|
7590
|
+
</section>`;
|
|
7591
|
+
}
|
|
7313
7592
|
function renderReviewsSection(settings) {
|
|
7314
7593
|
return `<section class="prb-settings-section">
|
|
7315
7594
|
<h2>Reviews</h2>
|
|
@@ -7568,7 +7847,7 @@ function defaultFetchDeps() {
|
|
|
7568
7847
|
function renderNotificationsPage(view) {
|
|
7569
7848
|
const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
|
|
7570
7849
|
return `<!doctype html>
|
|
7571
|
-
<html lang="en">
|
|
7850
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
7572
7851
|
<head>
|
|
7573
7852
|
<meta charset="utf-8">
|
|
7574
7853
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -7765,6 +8044,8 @@ function reviewsRevision(live = analysisStates()) {
|
|
|
7765
8044
|
hash.update(`queue:${queueStamp()}\n`);
|
|
7766
8045
|
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
7767
8046
|
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
8047
|
+
for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
|
|
8048
|
+
for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
|
|
7768
8049
|
return hash.digest("base64url");
|
|
7769
8050
|
}
|
|
7770
8051
|
//#endregion
|
|
@@ -8643,104 +8924,6 @@ function readConfiguredEditor() {
|
|
|
8643
8924
|
return null;
|
|
8644
8925
|
}
|
|
8645
8926
|
}
|
|
8646
|
-
//#endregion
|
|
8647
|
-
//#region ../../packages/review-harness/src/workspace/preferences.ts
|
|
8648
|
-
/**
|
|
8649
|
-
* Workspace preferences that belong to the reviewer rather than to a review.
|
|
8650
|
-
*
|
|
8651
|
-
* Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
|
|
8652
|
-
* origin and this server took whatever port the OS offered on each start, so a restart would
|
|
8653
|
-
* silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
|
|
8654
|
-
* fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
|
|
8655
|
-
* making on its own merits rather than in passing, and there is a second reason it stays for now:
|
|
8656
|
-
* `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
|
|
8657
|
-
* it under them the first time they save, which is why this is not simply a key in there.
|
|
8658
|
-
*
|
|
8659
|
-
* Everything here is best-effort. A workspace that will not open because a preferences file is
|
|
8660
|
-
* unreadable would be a preference costing more than it is worth.
|
|
8661
|
-
*/
|
|
8662
|
-
var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
|
|
8663
|
-
var MAX_ASK_WIDTH = 1200;
|
|
8664
|
-
/** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
|
|
8665
|
-
var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
|
|
8666
|
-
function readPreferences() {
|
|
8667
|
-
try {
|
|
8668
|
-
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
8669
|
-
if (!parsed || typeof parsed !== "object") return {};
|
|
8670
|
-
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister } = parsed;
|
|
8671
|
-
return {
|
|
8672
|
-
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
8673
|
-
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
8674
|
-
...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
|
|
8675
|
-
...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {}
|
|
8676
|
-
};
|
|
8677
|
-
} catch {
|
|
8678
|
-
return {};
|
|
8679
|
-
}
|
|
8680
|
-
}
|
|
8681
|
-
function writeAskWidth(width) {
|
|
8682
|
-
if (!Number.isFinite(width)) return;
|
|
8683
|
-
try {
|
|
8684
|
-
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8685
|
-
const next = {
|
|
8686
|
-
...readPreferences(),
|
|
8687
|
-
askWidth: clampAskWidth(width)
|
|
8688
|
-
};
|
|
8689
|
-
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
8690
|
-
} catch {}
|
|
8691
|
-
}
|
|
8692
|
-
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
8693
|
-
function writePromptsCollapsed(collapsed) {
|
|
8694
|
-
try {
|
|
8695
|
-
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8696
|
-
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
8697
|
-
...readPreferences(),
|
|
8698
|
-
promptsCollapsed: collapsed
|
|
8699
|
-
}, null, 2)}\n`);
|
|
8700
|
-
} catch {}
|
|
8701
|
-
}
|
|
8702
|
-
/** Remember which words the change map is read in. Read-modify-write, like every other field here. */
|
|
8703
|
-
function writeChangeMapRegister(register) {
|
|
8704
|
-
try {
|
|
8705
|
-
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8706
|
-
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
8707
|
-
...readPreferences(),
|
|
8708
|
-
changeMapRegister: register
|
|
8709
|
-
}, null, 2)}\n`);
|
|
8710
|
-
} catch {}
|
|
8711
|
-
}
|
|
8712
|
-
/** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
|
|
8713
|
-
function dismissNotice(key) {
|
|
8714
|
-
if (!NOTICE_KEY.test(key)) return false;
|
|
8715
|
-
try {
|
|
8716
|
-
const current = readPreferences();
|
|
8717
|
-
const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
|
|
8718
|
-
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8719
|
-
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
8720
|
-
...current,
|
|
8721
|
-
dismissedNotices
|
|
8722
|
-
}, null, 2)}\n`);
|
|
8723
|
-
return true;
|
|
8724
|
-
} catch {
|
|
8725
|
-
return false;
|
|
8726
|
-
}
|
|
8727
|
-
}
|
|
8728
|
-
/** Forget a stated width, so the stylesheet's proportional default applies again. */
|
|
8729
|
-
function clearAskWidth() {
|
|
8730
|
-
try {
|
|
8731
|
-
const { askWidth: _dropped, ...rest } = readPreferences();
|
|
8732
|
-
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8733
|
-
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
|
|
8734
|
-
} catch {}
|
|
8735
|
-
}
|
|
8736
|
-
/**
|
|
8737
|
-
* Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
|
|
8738
|
-
* the page enforces because it is the only party that knows how wide the window is — this is the
|
|
8739
|
-
* far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
|
|
8740
|
-
*/
|
|
8741
|
-
function clampAskWidth(width) {
|
|
8742
|
-
return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
|
|
8743
|
-
}
|
|
8744
8927
|
/** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
|
|
8745
8928
|
var MAX_PROMPT_CHARS = 2e3;
|
|
8746
8929
|
/**
|
|
@@ -9029,6 +9212,7 @@ async function handle(req, res, options) {
|
|
|
9029
9212
|
if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
|
|
9030
9213
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
9031
9214
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
9215
|
+
if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
|
|
9032
9216
|
if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
|
|
9033
9217
|
if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
|
|
9034
9218
|
if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
|
|
@@ -9119,9 +9303,11 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
9119
9303
|
if (onward) return redirect(res, onward, cookie);
|
|
9120
9304
|
const states = analysisStates();
|
|
9121
9305
|
const queue = readQueue();
|
|
9122
|
-
const
|
|
9123
|
-
|
|
9124
|
-
const
|
|
9306
|
+
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
9307
|
+
const kept = indexKeptIds(reviews, queue);
|
|
9308
|
+
const everyGroup = groupByLineage(reviews);
|
|
9309
|
+
const activity = reviewActivity(everyGroup);
|
|
9310
|
+
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup);
|
|
9125
9311
|
const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
|
|
9126
9312
|
sendHtml(res, 200, renderReviewsPage({
|
|
9127
9313
|
groups,
|
|
@@ -9129,9 +9315,11 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
9129
9315
|
analyses: states,
|
|
9130
9316
|
moved: movedReviews(),
|
|
9131
9317
|
prStates: prStatesByRequest(),
|
|
9318
|
+
activity,
|
|
9132
9319
|
revision: reviewsRevision(states),
|
|
9133
9320
|
unreadNotifications: unreadCount$1(),
|
|
9134
|
-
driving: isDriving()
|
|
9321
|
+
driving: isDriving(),
|
|
9322
|
+
theme: readPreferences().theme
|
|
9135
9323
|
}), cookie);
|
|
9136
9324
|
}
|
|
9137
9325
|
/**
|
|
@@ -9203,7 +9391,8 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
9203
9391
|
const { settings, error } = saveSettings({
|
|
9204
9392
|
...typeof body?.port === "string" ? { port: body.port } : {},
|
|
9205
9393
|
...typeof body?.agent === "string" ? { agent: body.agent } : {},
|
|
9206
|
-
...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {}
|
|
9394
|
+
...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {},
|
|
9395
|
+
...typeof body?.theme === "string" ? { theme: body.theme } : {}
|
|
9207
9396
|
}, MIN_WORKSPACE_PORT, MAX_WORKSPACE_PORT);
|
|
9208
9397
|
return send(res, 200, {
|
|
9209
9398
|
port: {
|
|
@@ -9212,6 +9401,7 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
9212
9401
|
},
|
|
9213
9402
|
agent: settings.agent,
|
|
9214
9403
|
usageCounts: settings.usageCounts,
|
|
9404
|
+
theme: settings.theme,
|
|
9215
9405
|
restartRequired: restartRequired(settings),
|
|
9216
9406
|
error
|
|
9217
9407
|
});
|
|
@@ -9303,7 +9493,8 @@ async function handleNotifications(req, res, params, indexToken) {
|
|
|
9303
9493
|
notifications,
|
|
9304
9494
|
gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
|
|
9305
9495
|
unreadNotifications: unreadCount$1(),
|
|
9306
|
-
driving: isDriving()
|
|
9496
|
+
driving: isDriving(),
|
|
9497
|
+
theme: readPreferences().theme
|
|
9307
9498
|
}), indexCookieHeader(indexToken));
|
|
9308
9499
|
}
|
|
9309
9500
|
/**
|
|
@@ -9363,6 +9554,74 @@ async function handleStatus(req, res, indexToken) {
|
|
|
9363
9554
|
});
|
|
9364
9555
|
}
|
|
9365
9556
|
/**
|
|
9557
|
+
* Put a selection in Active, Waiting or Done.
|
|
9558
|
+
*
|
|
9559
|
+
* The same shape and the same credential as `handleStatus` beside it, for its reasons: this is the
|
|
9560
|
+
* list acting on itself, and a lifecycle is a note about what happens next, not a change to the
|
|
9561
|
+
* analysis, so nothing in flight is a reason to refuse it.
|
|
9562
|
+
*
|
|
9563
|
+
* **A state that is stated and unknown is refused whole**, naming it and what would have worked.
|
|
9564
|
+
* Guessing (reading `archived` as Done, say) would put cards where the reviewer did not ask for
|
|
9565
|
+
* them, and skipping it would answer 200 to a request that did nothing.
|
|
9566
|
+
*
|
|
9567
|
+
* Waiting and Done both leave the queue: it holds work somebody means to do next, and either mark
|
|
9568
|
+
* says there is none. Active does not put anything back in, as un-marking a review does not.
|
|
9569
|
+
*
|
|
9570
|
+
* Marking Waiting again writes a new `at`, which is what settles a Waiting review that changed: the
|
|
9571
|
+
* reviewer has seen the change and parked it again.
|
|
9572
|
+
*/
|
|
9573
|
+
async function handleLifecycle(req, res, indexToken, baseline) {
|
|
9574
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
9575
|
+
const body = await readJson(req);
|
|
9576
|
+
const state = body?.state;
|
|
9577
|
+
if (!isLifecycle(state)) {
|
|
9578
|
+
const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
|
|
9579
|
+
return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
|
|
9580
|
+
}
|
|
9581
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string") : [];
|
|
9582
|
+
const now = Date.now();
|
|
9583
|
+
let changed = 0;
|
|
9584
|
+
for (const id of ids) {
|
|
9585
|
+
if (!mutateWorkspace(id, (current) => {
|
|
9586
|
+
current.lifecycle = state === "active" ? void 0 : {
|
|
9587
|
+
state,
|
|
9588
|
+
at: now
|
|
9589
|
+
};
|
|
9590
|
+
})) continue;
|
|
9591
|
+
if (state !== "active") queueRemove(id);
|
|
9592
|
+
changed += 1;
|
|
9593
|
+
}
|
|
9594
|
+
send(res, 200, {
|
|
9595
|
+
ok: true,
|
|
9596
|
+
changed,
|
|
9597
|
+
at: now,
|
|
9598
|
+
queue: readQueue()
|
|
9599
|
+
});
|
|
9600
|
+
if (state === "waiting") {
|
|
9601
|
+
const unseen = unobserved(ids);
|
|
9602
|
+
if (unseen.size > 0) baseline(unseen);
|
|
9603
|
+
}
|
|
9604
|
+
}
|
|
9605
|
+
/**
|
|
9606
|
+
* Which of these reviews the watcher has never seen the head of.
|
|
9607
|
+
*
|
|
9608
|
+
* Those are the ones Waiting cannot yet work for: a first observation is not a change (it says
|
|
9609
|
+
* nothing about when the head moved), so a push before it would be absorbed as the baseline. A
|
|
9610
|
+
* review already observed has its baseline, and polling it again now would only spend a request.
|
|
9611
|
+
*/
|
|
9612
|
+
function unobserved(ids) {
|
|
9613
|
+
const seen = new Set(Object.values(readWatch()).filter((entry) => entry.observedSha !== void 0).map((entry) => entry.reviewId));
|
|
9614
|
+
return new Set(ids.filter((id) => !seen.has(id)));
|
|
9615
|
+
}
|
|
9616
|
+
/**
|
|
9617
|
+
* The first look itself, by the watcher's own rules. A review that cannot be observed (the forge is
|
|
9618
|
+
* unreachable, or it is a commit review with nothing to watch) stays Waiting with no baseline, and
|
|
9619
|
+
* no change is invented for it: the next successful poll sets one.
|
|
9620
|
+
*/
|
|
9621
|
+
function establishBaseline(reviewIds) {
|
|
9622
|
+
watchAllNow(reviewIds).catch(() => {});
|
|
9623
|
+
}
|
|
9624
|
+
/**
|
|
9366
9625
|
* Has anything the reviews index draws changed, and is anything still being made.
|
|
9367
9626
|
*
|
|
9368
9627
|
* The whole of the index's liveness, in one request that costs the same whether the machine holds
|
|
@@ -9457,9 +9716,27 @@ async function handleQueue(req, res, indexToken) {
|
|
|
9457
9716
|
if (action === "add") compactQueue(readQueue());
|
|
9458
9717
|
const stored = action === "add" ? queueAdd(id) : queueRemove(id);
|
|
9459
9718
|
if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
|
|
9719
|
+
if (action === "add") activateQueued([id], stored);
|
|
9460
9720
|
send(res, 200, { queue: compactQueue(stored) });
|
|
9461
9721
|
}
|
|
9462
9722
|
/**
|
|
9723
|
+
* Queueing a review puts it back in Active.
|
|
9724
|
+
*
|
|
9725
|
+
* The queue is work the reviewer wants surfaced next, and Waiting and Done both say the opposite; a
|
|
9726
|
+
* queued review folded away in Done would be a contradiction the page then has to draw. Parking
|
|
9727
|
+
* takes a review out of the queue, and this is the same rule read the other way. Only what actually
|
|
9728
|
+
* went in: a review refused because the queue was full keeps the lifecycle it had.
|
|
9729
|
+
*/
|
|
9730
|
+
function activateQueued(ids, queue) {
|
|
9731
|
+
const queued = new Set(queue);
|
|
9732
|
+
for (const one of ids) {
|
|
9733
|
+
if (!queued.has(one) || loadWorkspace(one)?.lifecycle === void 0) continue;
|
|
9734
|
+
mutateWorkspace(one, (current) => {
|
|
9735
|
+
current.lifecycle = void 0;
|
|
9736
|
+
});
|
|
9737
|
+
}
|
|
9738
|
+
}
|
|
9739
|
+
/**
|
|
9463
9740
|
* Adding a selection, and saying which of it did not go in.
|
|
9464
9741
|
*
|
|
9465
9742
|
* `refused` has the shape bulk deletion's `blocked` has, a sentence per id, so the bar treats both
|
|
@@ -9473,6 +9750,7 @@ function addMany(res, ids) {
|
|
|
9473
9750
|
compactQueue(readQueue());
|
|
9474
9751
|
const outcome = queueAddMany(known);
|
|
9475
9752
|
if (outcome === null) return send(res, 500, { error: "The queue could not be saved." });
|
|
9753
|
+
activateQueued(known, outcome.queue);
|
|
9476
9754
|
const refused = [...outcome.full.map((one) => ({
|
|
9477
9755
|
id: one,
|
|
9478
9756
|
error: `The review queue is full. It holds 50 reviews at most.`
|
|
@@ -9709,6 +9987,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
9709
9987
|
editor: editorChoice(),
|
|
9710
9988
|
repoPath: workspace.repoPath,
|
|
9711
9989
|
askWidth: readPreferences().askWidth,
|
|
9990
|
+
theme: readPreferences().theme,
|
|
9712
9991
|
dismissedNotices: readPreferences().dismissedNotices,
|
|
9713
9992
|
promptsCollapsed: readPreferences().promptsCollapsed === true,
|
|
9714
9993
|
changeMapRegister: readPreferences().changeMapRegister ?? "simple",
|