prreviewbuddy 0.46.0 → 0.49.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 +87 -0
- package/dist/{delete_review-lmVdoybL.js → delete_review-BBjXPfMV.js} +183 -32
- package/dist/main.js +1 -3
- package/dist/server.js +596 -184
- package/package.json +1 -1
- package/static/chunk-BeqgAIrQ.js +1 -1
- package/static/chunk-vvGSmhmc.js +1 -0
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +259 -157
- package/static/workspace.js +62 -62
- 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 { $ as compactQueue, A as createPrState, An as processDiscussion, At as reviewedShaOf, B as toneOf, Bn as writeAgentPreference, Bt as progressSteps, C as readEvents, Cn as readQueue, D as announceJob, Dn as issuesOutstanding, Dt as checkCodeFreshness, E as telemetryUploadConsent, En as isQuestionOutstanding, Et as fillFileUrlTemplate, F as markAllRead, Fn as git, Ft as loadJob, G as extractJsonText, Gt as followedRefName, H as withUsageRecorded, Hn as STORE_ROOT, I as markRead, It as saveJob, J as readMarker, Jn as AgentCancelledError, Jt as indexReviewGroups, K as ReviewBeingDeletedError, Kn as agentFor, Kt as groupByLineage, L as pruneNotifications, Ln as resolveModel, M as eventName, Mt as endedAt, N as headline, Nt as fail, O as branchOf, On as questionsOutstanding, Ot as checkReview, P as listNotifications, Pn as isCommitReview, Pt as isTerminal, Q as activateQueued, Qt as loadWorkspace, R as readNotification, Rn as clearAgentPreference, Rt as isLiveJob, Sn as queueStamp, St as claimRequestUrl, T as recordTelemetryUploadConsent, Tn as isIssueOutstanding, Tt as remoteHost, U as EXPLAIN_SIMPLY_PROMPT, Un as AGENT_IDS, V as unreadCount$1, Vt as purposeOf, W as asString, Wt as isClaimed, Y as removeWorktree, Yn as AgentUnavailableError, Yt as latestKissRun, Z as relativeTime$1, Zt as lineagePosition, _ as agentEnvOf, _n as parseLifecycle, _t as MIN_WORKSPACE_PORT, a as wasBlocked, at as readServerRecord, bn as queueAddMany, bt as statedWorkspacePort, c as RefreshUnavailableError, cn as workspaceRevision, ct as spawnServer, d as refreshPrContext, dn as activityLabel, dt as writeServerRecord, en as mutateWorkspace, f as isUnchanged, fn as isLifecycle, ft as BUILD_VERSION, gn as offersLifecycle, gt as MAX_WORKSPACE_PORT, h as liveUpdateFor, hn as lifecyclePill, ht as DEFAULT_WORKSPACE_PORT, i as liveJobsFor, in as reviewedCommit, j as createPrUpdate, jn as describeAuthorship, jt as allJobs, k as subjectOf, kn as reviewerDispositions, kt as followedBranch, l as mergeUpdateInto, ln as workspaceStamps, m as UpdateAlreadyRunningError, mn as lifecycleChip, mt as feedbackUrl, n as deleteReview, nn as previousKissResult, o as discardJob, p as askCheckout, pn as lifecycleAction, q as MANAGED_ROOT, qt as indexKeptIds, r as lineageIds, rn as recentWorkspaces, rt as clearServerRecord, s as FETCH_TIMEOUT_MS, sn as touchWorkspace, st as serverEntryExists, t as deleteLineage, tt as readIndexToken, u as updateReview, un as LIFECYCLES, vn as sectionOf, vt as parseWorkspacePort, w as record, wn as doneVerb, wt as forgeFor, x as runJob, xn as queueRemove, xt as writePortPreference, y as startKissJob, yn as queueAdd, yt as resolveWorkspacePort, z as removeNotification, zn as readAgentPreference, zt as phasesFor } from "./delete_review-BBjXPfMV.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";
|
|
@@ -253,6 +253,14 @@ function saveItemLabel(saved) {
|
|
|
253
253
|
return saved ? "Remove from saved" : "Save review";
|
|
254
254
|
}
|
|
255
255
|
/**
|
|
256
|
+
* The queue item, on a card and on the review's own page. "review queue" rather than "queue",
|
|
257
|
+
* because a menu is where somebody meets the idea for the first time. Here because the browser
|
|
258
|
+
* relabels it after a change, on both pages.
|
|
259
|
+
*/
|
|
260
|
+
function queueItemLabel(queued) {
|
|
261
|
+
return queued ? "Remove from review queue" : "Add to review queue";
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
256
264
|
* What `Continue reviewing` opens, and whether it exists at all.
|
|
257
265
|
*
|
|
258
266
|
* One rule rather than an expression in the template, because "is there anything to continue" and
|
|
@@ -4550,6 +4558,46 @@ function renderPrState(state, besideNumber = false) {
|
|
|
4550
4558
|
return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : besideNumber ? "Closed" : "PR closed"}</span>`;
|
|
4551
4559
|
}
|
|
4552
4560
|
//#endregion
|
|
4561
|
+
//#region ../../packages/review-harness/src/workspace/theme.ts
|
|
4562
|
+
/**
|
|
4563
|
+
* Which palette the workspace's furniture is drawn in: the reviewer's device, or a fixed choice.
|
|
4564
|
+
*
|
|
4565
|
+
* One axis with a registry rather than a flag per value, so the stored type, the settings page's
|
|
4566
|
+
* radios and the attribute on `<html>` all derive from the same list and cannot disagree about
|
|
4567
|
+
* what the choices are.
|
|
4568
|
+
*
|
|
4569
|
+
* Pure on purpose: the settings page's script imports this, and the client bundle must not reach
|
|
4570
|
+
* anything that pulls in a node builtin.
|
|
4571
|
+
*
|
|
4572
|
+
* The attribute is the whole mechanism. shell.css sets `color-scheme` from it, and every shell
|
|
4573
|
+
* token is a `light-dark()` pair that the browser resolves from `color-scheme`, so System follows
|
|
4574
|
+
* the device live, with no listener and no reload.
|
|
4575
|
+
*/
|
|
4576
|
+
var THEMES = [
|
|
4577
|
+
{
|
|
4578
|
+
id: "system",
|
|
4579
|
+
label: "System",
|
|
4580
|
+
help: "Follow your device setting"
|
|
4581
|
+
},
|
|
4582
|
+
{
|
|
4583
|
+
id: "light",
|
|
4584
|
+
label: "Light",
|
|
4585
|
+
help: "Always use light mode"
|
|
4586
|
+
},
|
|
4587
|
+
{
|
|
4588
|
+
id: "dark",
|
|
4589
|
+
label: "Dark",
|
|
4590
|
+
help: "Always use dark mode"
|
|
4591
|
+
}
|
|
4592
|
+
];
|
|
4593
|
+
function isTheme(value) {
|
|
4594
|
+
return THEMES.some((theme) => theme.id === value);
|
|
4595
|
+
}
|
|
4596
|
+
/** The `<html>` attribute, leading space included, so a root reads `<html lang="en"${…}>`. */
|
|
4597
|
+
function themeAttribute(theme) {
|
|
4598
|
+
return ` data-theme="${theme ?? "system"}"`;
|
|
4599
|
+
}
|
|
4600
|
+
//#endregion
|
|
4553
4601
|
//#region ../../packages/review-harness/src/workspace/page.ts
|
|
4554
4602
|
function renderWorkspace(view) {
|
|
4555
4603
|
const { session, changeSet } = view;
|
|
@@ -4569,7 +4617,7 @@ function renderWorkspace(view) {
|
|
|
4569
4617
|
*/
|
|
4570
4618
|
const freshness = result ? view.freshness : void 0;
|
|
4571
4619
|
return `<!doctype html>
|
|
4572
|
-
<html lang="en">
|
|
4620
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
4573
4621
|
<head>
|
|
4574
4622
|
<meta charset="utf-8">
|
|
4575
4623
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -4658,9 +4706,30 @@ function renderReviewBody(view, sections) {
|
|
|
4658
4706
|
${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
|
|
4659
4707
|
</main>
|
|
4660
4708
|
${result ? renderAsk(view, sections, updating(view)) : ""}
|
|
4709
|
+
${result ? renderAskTab() : ""}
|
|
4661
4710
|
</div>`;
|
|
4662
4711
|
}
|
|
4663
4712
|
/**
|
|
4713
|
+
* Buddy AI's own way in: a tab on the window's right edge, where its pane opens.
|
|
4714
|
+
*
|
|
4715
|
+
* Out of the header, which says where the review stands; the assistant is a tool beside the review,
|
|
4716
|
+
* and the edge it lives on is the edge this sits on. Hidden by the stylesheet whenever the pane is
|
|
4717
|
+
* showing, where the chevron in the pane's own header closes it again. The pane's left border is
|
|
4718
|
+
* not free for it: the resize handle runs its full height.
|
|
4719
|
+
*
|
|
4720
|
+
* Horizontal, and only as wide as its words: the product reads left to right everywhere else, and
|
|
4721
|
+
* a vertical label was a pattern it would have used exactly once.
|
|
4722
|
+
*
|
|
4723
|
+
* Only with a standard review, like the pane. A control that opens nothing is worse than none.
|
|
4724
|
+
*/
|
|
4725
|
+
function renderAskTab() {
|
|
4726
|
+
return `<button type="button" class="prb-ask-tab" data-ask-toggle aria-controls="prb-ask" aria-expanded="false">${ASK_TAB_ICON}<span class="prb-ask-tab-label">Buddy AI</span></button>`;
|
|
4727
|
+
}
|
|
4728
|
+
/** Four-pointed, stroked like the rail's icons, so it reads as one of the product's own marks. */
|
|
4729
|
+
var ASK_TAB_ICON = "<svg class=\"prb-ask-tab-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" focusable=\"false\" aria-hidden=\"true\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linejoin=\"round\"><path d=\"M12 3l1.8 5.6a2 2 0 0 0 1.3 1.3L21 12l-5.9 2.1a2 2 0 0 0-1.3 1.3L12 21l-1.8-5.6a2 2 0 0 0-1.3-1.3L3 12l5.9-2.1a2 2 0 0 0 1.3-1.3z\"/></svg>";
|
|
4730
|
+
/** Two chevrons pointing where the pane goes. */
|
|
4731
|
+
var ASK_HIDE_ICON = "<svg viewBox=\"0 0 24 24\" width=\"15\" height=\"15\" focusable=\"false\" aria-hidden=\"true\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 17 5-5-5-5M13 17l5-5-5-5\"/></svg>";
|
|
4732
|
+
/**
|
|
4664
4733
|
* The document while an update runs: its progress and nothing else.
|
|
4665
4734
|
*
|
|
4666
4735
|
* Deliberately not a `.prb-view`. Views are what the rail and the hash switch between, and a page
|
|
@@ -4967,42 +5036,69 @@ function renderReviewBar(view, freshness) {
|
|
|
4967
5036
|
<span><span class="prb-add">+${additions}</span> <span class="prb-del">−${deletions}</span></span>
|
|
4968
5037
|
${freshness || view.remote ? (() => {
|
|
4969
5038
|
const { label, state } = freshnessLabel(freshness ?? null, view.remote ?? null);
|
|
4970
|
-
return `<span class="prb-fresh-status" data-fresh-status data-state="${state}">${escapeHtml$1(label)}</span
|
|
5039
|
+
return `<span class="prb-fresh-status" data-fresh-status data-state="${state}">${escapeHtml$1(label)}</span>`;
|
|
4971
5040
|
})() : ""}
|
|
4972
|
-
${view.token ?
|
|
4973
|
-
${view.id ? renderIdMenu(view
|
|
4974
|
-
${view.session.result ? "<button type=\"button\" class=\"prb-ask-toggle\" data-ask-toggle aria-expanded=\"false\">Buddy AI</button>" : ""}
|
|
5041
|
+
${view.token ? renderStatePills(view) : ""}
|
|
5042
|
+
${view.id ? renderIdMenu(view, freshness) : ""}
|
|
4975
5043
|
</div>
|
|
4976
5044
|
</div>`;
|
|
4977
5045
|
}
|
|
4978
5046
|
/**
|
|
4979
|
-
*
|
|
5047
|
+
* Where this review stands, as pills that say and never do.
|
|
4980
5048
|
*
|
|
4981
|
-
*
|
|
4982
|
-
*
|
|
4983
|
-
*
|
|
4984
|
-
*
|
|
4985
|
-
* the address bar.
|
|
5049
|
+
* Two, because they answer two questions. Reviewed or In progress is what the reviewer has done;
|
|
5050
|
+
* Waiting or Done is what happens next with the branch. A review can be Reviewed and Waiting, so a
|
|
5051
|
+
* single pill would have to drop one of them. Active draws nothing, as an open pull request does
|
|
5052
|
+
* not: it is where every review starts, and a pill that never varies teaches nothing.
|
|
4986
5053
|
*
|
|
4987
|
-
*
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
5054
|
+
* Never "New" here, because being on this page is what ends it.
|
|
5055
|
+
*/
|
|
5056
|
+
function renderStatePills(view) {
|
|
5057
|
+
const status = view.complete ? "complete" : "in-progress";
|
|
5058
|
+
const pill = lifecyclePill(view.lifecycle, view.activity);
|
|
5059
|
+
return `<span class="prb-card-status" data-status="${status}" data-page-status>${statusLabel(status)}</span>
|
|
5060
|
+
<span class="prb-card-status prb-page-lifecycle" data-page-lifecycle${pill?.changed ? " data-changed" : ""}${pill ? "" : " hidden"}>${escapeHtml$1(pill?.text ?? "")}</span>`;
|
|
5061
|
+
}
|
|
5062
|
+
/**
|
|
5063
|
+
* Everything that can be done to this review, from the page you are reading it on.
|
|
4991
5064
|
*
|
|
4992
|
-
*
|
|
4993
|
-
*
|
|
4994
|
-
*
|
|
4995
|
-
*
|
|
5065
|
+
* The header only says where the review stands; this is where that is changed. It holds what the
|
|
5066
|
+
* Reviews list's card menu holds, in the same words from the same registries, so nobody has to go
|
|
5067
|
+
* back to the list to park or queue the review they are in. Check for updates leads because it is
|
|
5068
|
+
* the one thing here the list does not offer, and it is here rather than in the header because the
|
|
5069
|
+
* page already checks by itself, every minute while visible and on coming back to the tab.
|
|
5070
|
+
*
|
|
5071
|
+
* Waiting, Done and the queue belong to the branch, so they are offered on its newest review only,
|
|
5072
|
+
* as on the list, where the older reviews' rows carry none. The lineage notice already points at
|
|
5073
|
+
* the newest one.
|
|
5074
|
+
*
|
|
5075
|
+
* Delete keeps the index's confirmation, in the same panel with the same sentence. The list is
|
|
5076
|
+
* where you land afterwards, since the page you were reading no longer exists.
|
|
5077
|
+
*
|
|
5078
|
+
* Nothing that writes where there is no server, which is the offline preview: each item would be a
|
|
5079
|
+
* lie there. Copy review ID stays, because it is the name `prreviewbuddy review --review <review>
|
|
5080
|
+
* --delete` takes, and the page devoted to one review should be able to say which one it is.
|
|
4996
5081
|
*/
|
|
4997
|
-
function renderIdMenu(
|
|
4998
|
-
const safe = escapeHtml$1(id);
|
|
4999
|
-
|
|
5082
|
+
function renderIdMenu(view, freshness) {
|
|
5083
|
+
const safe = escapeHtml$1(view.id ?? "");
|
|
5084
|
+
const canWrite = Boolean(view.token);
|
|
5085
|
+
const latest = !view.lineage || view.lineage.position >= view.lineage.total;
|
|
5086
|
+
const mark = view.complete ? "in-progress" : "complete";
|
|
5087
|
+
const branchItems = canWrite && latest;
|
|
5088
|
+
const items = [
|
|
5089
|
+
canWrite && (freshness || view.remote) ? "<button type=\"button\" class=\"prb-menu-item\" data-fresh-check>Check for updates</button>" : "",
|
|
5090
|
+
canWrite ? `<button type="button" class="prb-menu-item" data-mark-status="${mark}">${markItemLabel(mark)}</button>` : "",
|
|
5091
|
+
...branchItems ? LIFECYCLES.map(({ id }) => `<button type="button" class="prb-menu-item" data-lifecycle-set="${id}"${offersLifecycle(id, view.lifecycle, view.activity) ? "" : " hidden"}>${escapeHtml$1(lifecycleAction(id))}</button>`) : [],
|
|
5092
|
+
branchItems ? `<button type="button" class="prb-menu-item" data-queue-action="${view.queued ? "remove" : "add"}">${queueItemLabel(view.queued === true)}</button>` : "",
|
|
5093
|
+
canWrite ? `<button type="button" class="prb-menu-item" data-save-toggle aria-pressed="${view.saved === true}">${saveItemLabel(view.saved === true)}</button>` : "",
|
|
5094
|
+
`<button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>`,
|
|
5095
|
+
canWrite ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""
|
|
5096
|
+
].filter(Boolean);
|
|
5097
|
+
return `<div class="prb-menu" data-menu data-review-menu${[view.lifecycle ? ` data-lifecycle-state="${view.lifecycle.state}" data-lifecycle-at="${view.lifecycle.at}"` : "", view.activity ? ` data-activity-at="${view.activity.at}" data-activity-kind="${view.activity.kind}"` : ""].join("")}>
|
|
5000
5098
|
<button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Actions for this review">⋯</button>
|
|
5001
5099
|
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
5002
5100
|
<div data-menu-items>
|
|
5003
|
-
${
|
|
5004
|
-
<button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>
|
|
5005
|
-
${canWrite ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""}
|
|
5101
|
+
${items.join("\n ")}
|
|
5006
5102
|
</div>
|
|
5007
5103
|
${canWrite ? `<div class="prb-menu-confirm" data-menu-confirm hidden>
|
|
5008
5104
|
<p class="prb-menu-question">Delete this review?</p>
|
|
@@ -5019,7 +5115,7 @@ function renderIdMenu(id, canWrite, saved) {
|
|
|
5019
5115
|
* Everything below the review: where else to go, and what to do to the server.
|
|
5020
5116
|
*
|
|
5021
5117
|
* These are in the rail rather than the header because they are not about this review. The header
|
|
5022
|
-
* answers "what am I looking at" — branch, size,
|
|
5118
|
+
* answers "what am I looking at" — branch, size, where the review stands — and
|
|
5023
5119
|
* anything global sitting up there competed with that. Leaving the review is navigation, and it
|
|
5024
5120
|
* belongs with the other navigation.
|
|
5025
5121
|
*
|
|
@@ -5529,7 +5625,7 @@ function renderAsk(view, sections, paused = false) {
|
|
|
5529
5625
|
const pending = view.pendingAsk ?? null;
|
|
5530
5626
|
const pendingTurn = pending ? `<div class="prb-msg prb-msg-user">${escapeHtml$1(pending.question ?? "")}</div>
|
|
5531
5627
|
<div class="prb-thinking" data-ask-pending><i></i><i></i><i></i><span>Reading the change…</span></div>` : "";
|
|
5532
|
-
return `<aside class="prb-ask" aria-label="Ask Buddy AI">
|
|
5628
|
+
return `<aside class="prb-ask" id="prb-ask" aria-label="Ask Buddy AI">
|
|
5533
5629
|
<div
|
|
5534
5630
|
class="prb-ask-resize"
|
|
5535
5631
|
data-ask-resize
|
|
@@ -5542,6 +5638,7 @@ function renderAsk(view, sections, paused = false) {
|
|
|
5542
5638
|
<h2>Ask Buddy AI</h2>
|
|
5543
5639
|
<span class="prb-ask-scope" data-ask-scope>whole change</span>
|
|
5544
5640
|
${renderFocusToggle("buddy", "Buddy AI")}
|
|
5641
|
+
<button type="button" class="prb-ask-hide" data-ask-toggle aria-controls="prb-ask" aria-label="Hide Buddy AI">${ASK_HIDE_ICON}</button>
|
|
5545
5642
|
</div>
|
|
5546
5643
|
${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
|
|
5547
5644
|
<div class="prb-ask-log" data-ask-log>
|
|
@@ -5718,7 +5815,8 @@ function normalise$1(entries) {
|
|
|
5718
5815
|
if (typeof raw.observedAt !== "number") continue;
|
|
5719
5816
|
states[key] = {
|
|
5720
5817
|
state: raw.state,
|
|
5721
|
-
observedAt: raw.observedAt
|
|
5818
|
+
observedAt: raw.observedAt,
|
|
5819
|
+
...typeof raw.changedAt === "number" ? { changedAt: raw.changedAt } : {}
|
|
5722
5820
|
};
|
|
5723
5821
|
}
|
|
5724
5822
|
return states;
|
|
@@ -5834,6 +5932,7 @@ function normalise(entries) {
|
|
|
5834
5932
|
if (typeof raw.polledAt === "number") entry.polledAt = raw.polledAt;
|
|
5835
5933
|
if (typeof raw.observedSha === "string") entry.observedSha = raw.observedSha;
|
|
5836
5934
|
if (typeof raw.observedAt === "number") entry.observedAt = raw.observedAt;
|
|
5935
|
+
if (typeof raw.changedAt === "number") entry.changedAt = raw.changedAt;
|
|
5837
5936
|
if (typeof raw.notifiedSha === "string") entry.notifiedSha = raw.notifiedSha;
|
|
5838
5937
|
if (typeof raw.notificationId === "string") entry.notificationId = raw.notificationId;
|
|
5839
5938
|
if (typeof raw.failures === "number") entry.failures = raw.failures;
|
|
@@ -6172,10 +6271,12 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
|
|
|
6172
6271
|
}
|
|
6173
6272
|
if (observed.kind === "unresolved") continue;
|
|
6174
6273
|
if (observed.prState !== void 0 && !isBranchCandidate(candidate)) recordPrState(candidate, key, observed.sha, observed.prState, prStates, now);
|
|
6274
|
+
const lastSeen = state[key]?.observedSha;
|
|
6175
6275
|
state[key] = {
|
|
6176
6276
|
...state[key],
|
|
6177
6277
|
observedSha: observed.sha,
|
|
6178
6278
|
observedAt: seenAt,
|
|
6279
|
+
...lastSeen === void 0 || observed.sha === lastSeen ? {} : { changedAt: seenAt },
|
|
6179
6280
|
failures: 0
|
|
6180
6281
|
};
|
|
6181
6282
|
if (observed.sha === candidate.analysedSha) {
|
|
@@ -6220,7 +6321,8 @@ function prunedPrStates(prStates, deps) {
|
|
|
6220
6321
|
*/
|
|
6221
6322
|
function recordPrState(candidate, key, headSha, observed, prStates, now) {
|
|
6222
6323
|
if (observed === null) return;
|
|
6223
|
-
|
|
6324
|
+
const previous = prStates[key]?.state;
|
|
6325
|
+
if (previous === "open" && observed !== "open") {
|
|
6224
6326
|
const id = `pr-${observed}-${candidate.reviewId}-${headSha}`;
|
|
6225
6327
|
if (createPrState({
|
|
6226
6328
|
id,
|
|
@@ -6232,9 +6334,11 @@ function recordPrState(candidate, key, headSha, observed, prStates, now) {
|
|
|
6232
6334
|
headSha
|
|
6233
6335
|
}, now) === null && readNotification(id) === null) return;
|
|
6234
6336
|
}
|
|
6337
|
+
const changedAt = previous !== void 0 && previous !== observed ? now : prStates[key]?.changedAt;
|
|
6235
6338
|
prStates[key] = {
|
|
6236
6339
|
state: observed,
|
|
6237
|
-
observedAt: now
|
|
6340
|
+
observedAt: now,
|
|
6341
|
+
...changedAt === void 0 ? {} : { changedAt }
|
|
6238
6342
|
};
|
|
6239
6343
|
}
|
|
6240
6344
|
async function observeRequest(candidate, deps, unreachable) {
|
|
@@ -6400,6 +6504,12 @@ async function announce(candidate, key, observed, state, deps, now) {
|
|
|
6400
6504
|
* and a review with no `completedAt` is one they have not finished. Anything outside those is a
|
|
6401
6505
|
* review they are done with, and polling it would spend a request on an answer nobody wants.
|
|
6402
6506
|
*
|
|
6507
|
+
* **Waiting and Done are watched whatever else is true of them.** Parking a review takes it out of
|
|
6508
|
+
* the queue and is usually done after marking it reviewed, so without this a Waiting review would
|
|
6509
|
+
* fall outside all three sets at the moment it starts waiting for a push, and the push that should
|
|
6510
|
+
* bring it back to Active would never be seen. Done is watched for "Changed since done": the reviewer
|
|
6511
|
+
* asked not to be interrupted, not to be kept uninformed.
|
|
6512
|
+
*
|
|
6403
6513
|
* The one exclusion is structural rather than editorial: a commit review is of one fixed commit
|
|
6404
6514
|
* that cannot move, and `freshness.ts` calls that state `fixed` for the same reason. A review with
|
|
6405
6515
|
* no pull request used to be excluded too, having nothing to ask a forge about. It is watched
|
|
@@ -6407,7 +6517,7 @@ async function announce(candidate, key, observed, state, deps, now) {
|
|
|
6407
6517
|
*/
|
|
6408
6518
|
function isWatchable(workspace, queued) {
|
|
6409
6519
|
if (isCommitReview(workspace.changeSet)) return false;
|
|
6410
|
-
return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0;
|
|
6520
|
+
return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0 || parseLifecycle(workspace.lifecycle) !== void 0;
|
|
6411
6521
|
}
|
|
6412
6522
|
/** The request a review is attached to, by URL where there is one and by number otherwise. */
|
|
6413
6523
|
function requestOf(workspace) {
|
|
@@ -6498,6 +6608,50 @@ function prStatesByRequest(states = readPrStates()) {
|
|
|
6498
6608
|
return new Map(Object.entries(states).map(([key, entry]) => [key, entry.state]));
|
|
6499
6609
|
}
|
|
6500
6610
|
/**
|
|
6611
|
+
* What last happened to each branch's change, by the id of its newest review: the list's order and
|
|
6612
|
+
* the time on each card.
|
|
6613
|
+
*
|
|
6614
|
+
* The latest of three things, each of which is the change moving rather than anybody looking at it:
|
|
6615
|
+
* the newest analysis being made, its head moving, and its pull request changing state. Read from
|
|
6616
|
+
* the two `changedAt`s, never the `observedAt`s, which every poll rewrites.
|
|
6617
|
+
*
|
|
6618
|
+
* A watch entry follows the newest analysis of its request, so it is looked for across the whole
|
|
6619
|
+
* history, the same way `moved` is: an entry still filed against an older analysis is the same
|
|
6620
|
+
* change. A head move or state change from before the newest analysis is already in it, so only a
|
|
6621
|
+
* later one counts.
|
|
6622
|
+
*/
|
|
6623
|
+
function reviewActivity(groups, watch = readWatch(), prStates = readPrStates()) {
|
|
6624
|
+
const headChanges = /* @__PURE__ */ new Map();
|
|
6625
|
+
for (const entry of Object.values(watch)) {
|
|
6626
|
+
if (entry.reviewId === void 0 || entry.changedAt === void 0) continue;
|
|
6627
|
+
headChanges.set(entry.reviewId, Math.max(entry.changedAt, headChanges.get(entry.reviewId) ?? 0));
|
|
6628
|
+
}
|
|
6629
|
+
const activity = /* @__PURE__ */ new Map();
|
|
6630
|
+
for (const group of groups) {
|
|
6631
|
+
const { latest } = group;
|
|
6632
|
+
let current = {
|
|
6633
|
+
at: latest.createdAt,
|
|
6634
|
+
kind: "created"
|
|
6635
|
+
};
|
|
6636
|
+
for (const review of group.history) {
|
|
6637
|
+
const at = headChanges.get(review.id);
|
|
6638
|
+
if (at !== void 0 && at > current.at) current = {
|
|
6639
|
+
at,
|
|
6640
|
+
kind: "updated"
|
|
6641
|
+
};
|
|
6642
|
+
}
|
|
6643
|
+
if (latest.prNumber !== void 0) {
|
|
6644
|
+
const entry = prStates[requestKey(latest.repoPath, latest.prNumber)];
|
|
6645
|
+
if (entry?.changedAt !== void 0 && entry.changedAt > current.at) current = {
|
|
6646
|
+
at: entry.changedAt,
|
|
6647
|
+
kind: entry.state === "open" ? "reopened" : entry.state
|
|
6648
|
+
};
|
|
6649
|
+
}
|
|
6650
|
+
activity.set(latest.id, current);
|
|
6651
|
+
}
|
|
6652
|
+
return activity;
|
|
6653
|
+
}
|
|
6654
|
+
/**
|
|
6501
6655
|
* One review's pull request state, for its page, filed under the same key the list looks up. Null
|
|
6502
6656
|
* for a review with no request or one never observed: unknown, not open. Only ever what the watcher
|
|
6503
6657
|
* last saw; nothing here reaches the forge.
|
|
@@ -6561,6 +6715,23 @@ function watchNow(scope, makeDeps = defaultDeps) {
|
|
|
6561
6715
|
});
|
|
6562
6716
|
}
|
|
6563
6717
|
/**
|
|
6718
|
+
* A first look at every one of these reviews, however many there are.
|
|
6719
|
+
*
|
|
6720
|
+
* For parking a selection as Waiting. `watchNow` keeps the request budget of an ordinary cycle, so
|
|
6721
|
+
* handed thirty reviews it would look at twenty and leave ten to the timer, and a push to one of
|
|
6722
|
+
* those before its turn would be taken as its first observation and never bring it back. So the
|
|
6723
|
+
* selection goes through in batches of that budget, one scoped cycle after another: every review is
|
|
6724
|
+
* looked at, and no single cycle asks more of the forge than an ordinary one does. The whole
|
|
6725
|
+
* selection is the reviewer's own act, which is what bounds how many batches there can be.
|
|
6726
|
+
*/
|
|
6727
|
+
async function watchAllNow(reviewIds, makeDeps = defaultDeps) {
|
|
6728
|
+
const ids = [...reviewIds];
|
|
6729
|
+
for (let start = 0; start < ids.length; start += 20) {
|
|
6730
|
+
const batch = new Set(ids.slice(start, start + 20));
|
|
6731
|
+
await watchNow((candidate) => batch.has(candidate.reviewId), makeDeps);
|
|
6732
|
+
}
|
|
6733
|
+
}
|
|
6734
|
+
/**
|
|
6564
6735
|
* Run the pass on a timer, and return the stop.
|
|
6565
6736
|
*
|
|
6566
6737
|
* The same shape as `startSweeping`, and tied into `cli/server.ts` the same way, so a restart
|
|
@@ -6600,14 +6771,26 @@ var FETCH_SELECTED_LABEL = "Fetch selected";
|
|
|
6600
6771
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
6601
6772
|
function renderReviewsPage(view) {
|
|
6602
6773
|
const now = view.now ?? Date.now();
|
|
6603
|
-
const
|
|
6774
|
+
const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
|
|
6775
|
+
at: group.latest.createdAt,
|
|
6776
|
+
kind: "created"
|
|
6777
|
+
};
|
|
6778
|
+
const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || activityOf(b).at - activityOf(a).at);
|
|
6779
|
+
const sections = {
|
|
6780
|
+
active: [],
|
|
6781
|
+
waiting: [],
|
|
6782
|
+
done: []
|
|
6783
|
+
};
|
|
6784
|
+
for (const group of ordered) sections[sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
|
|
6785
|
+
const rank = new Map(ordered.map((group, index) => [group, index]));
|
|
6786
|
+
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
6787
|
const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
|
|
6605
6788
|
const queue = view.queue ?? [];
|
|
6606
6789
|
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
6607
6790
|
const continues = continueTarget(queue);
|
|
6608
6791
|
const front = continues === null ? void 0 : findReview(view.groups, continues);
|
|
6609
6792
|
return `<!doctype html>
|
|
6610
|
-
<html lang="en">
|
|
6793
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
6611
6794
|
<head>
|
|
6612
6795
|
<meta charset="utf-8">
|
|
6613
6796
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -6634,13 +6817,15 @@ ${renderWorkspaceRail({
|
|
|
6634
6817
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
6635
6818
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
6636
6819
|
</div>`}
|
|
6637
|
-
<div class="prb-cards" data-cards>
|
|
6638
|
-
${
|
|
6820
|
+
<div class="prb-cards" data-cards="active">
|
|
6821
|
+
${renderCards(sections.active)}
|
|
6639
6822
|
</div>
|
|
6823
|
+
${renderLoadMore("active")}
|
|
6640
6824
|
${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
|
-
${
|
|
6825
|
+
${renderSection("waiting", "Waiting", sections.waiting, true, renderCards)}
|
|
6826
|
+
${renderSection("done", "Done", sections.done, false, renderCards)}
|
|
6642
6827
|
${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>
|
|
6828
|
+
<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
6829
|
</main>
|
|
6645
6830
|
</div>
|
|
6646
6831
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
@@ -6721,22 +6906,31 @@ function findReview(groups, id) {
|
|
|
6721
6906
|
for (const group of groups) for (const review of group.history) if (review.id === id) return review;
|
|
6722
6907
|
}
|
|
6723
6908
|
/**
|
|
6724
|
-
*
|
|
6909
|
+
* One section's Load more, which the browser shows once that section holds more than it is showing.
|
|
6725
6910
|
*
|
|
6726
|
-
*
|
|
6727
|
-
*
|
|
6728
|
-
*
|
|
6729
|
-
|
|
6911
|
+
* One per section rather than one pager for the page, so revealing older Done reviews never costs
|
|
6912
|
+
* the reviewer their place in Active. Hidden until the client has counted, so a browser with no
|
|
6913
|
+
* JavaScript gets every card and no control that does nothing.
|
|
6914
|
+
*/
|
|
6915
|
+
function renderLoadMore(section) {
|
|
6916
|
+
return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
|
|
6917
|
+
}
|
|
6918
|
+
/**
|
|
6919
|
+
* Waiting or Done, as a disclosure under the working list.
|
|
6730
6920
|
*
|
|
6731
|
-
*
|
|
6732
|
-
*
|
|
6921
|
+
* Active has no heading: it is what the page has always been, and the count line above it already
|
|
6922
|
+
* says what it holds. Waiting starts open, because it is work that will come back; Done starts
|
|
6923
|
+
* closed, because it is the archive. Rendered empty and hidden rather than left out, so a card the
|
|
6924
|
+
* browser moves here after a mark has somewhere to go.
|
|
6733
6925
|
*/
|
|
6734
|
-
function
|
|
6735
|
-
return `<
|
|
6736
|
-
<
|
|
6737
|
-
<
|
|
6738
|
-
|
|
6739
|
-
|
|
6926
|
+
function renderSection(section, title, groups, open, renderCards) {
|
|
6927
|
+
return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
|
|
6928
|
+
<summary class="prb-index-section-head">${title} · <span data-section-count>${groups.length}</span></summary>
|
|
6929
|
+
<div class="prb-cards" data-cards="${section}">
|
|
6930
|
+
${renderCards(groups)}
|
|
6931
|
+
</div>
|
|
6932
|
+
${renderLoadMore(section)}
|
|
6933
|
+
</details>`;
|
|
6740
6934
|
}
|
|
6741
6935
|
/**
|
|
6742
6936
|
* The bulk bar, with its counts left out.
|
|
@@ -6761,6 +6955,7 @@ function renderBulkBar() {
|
|
|
6761
6955
|
<button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
|
|
6762
6956
|
<button type="button" class="prb-bulk-button" data-bulk-mark="complete">Mark reviewed</button>
|
|
6763
6957
|
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">Mark in progress</button>
|
|
6958
|
+
${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
|
|
6764
6959
|
<button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
|
|
6765
6960
|
<button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
|
|
6766
6961
|
</div>
|
|
@@ -6794,20 +6989,22 @@ function renderBulkBar() {
|
|
|
6794
6989
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
6795
6990
|
* than a rebuild of the list.
|
|
6796
6991
|
*/
|
|
6797
|
-
function renderCard(group, now, analysis, queued, moved, prState) {
|
|
6992
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
|
|
6798
6993
|
const review = group.latest;
|
|
6799
6994
|
const status = statusOf(review);
|
|
6995
|
+
const chip = lifecycleChip(review.lifecycle, activity);
|
|
6800
6996
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
6801
6997
|
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))}">
|
|
6998
|
+
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
6999
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
6804
7000
|
<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)}
|
|
7001
|
+
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
6806
7002
|
<a class="prb-card-link" href="${escapeHtml$1(href)}">
|
|
6807
7003
|
<div class="prb-card-head">
|
|
6808
7004
|
<h2>${escapeHtml$1(review.title)}</h2>
|
|
6809
7005
|
${renderStatus(status)}
|
|
6810
7006
|
${renderPrState(prState)}
|
|
7007
|
+
${`<span class="prb-card-lifecycle" data-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>`}
|
|
6811
7008
|
</div>
|
|
6812
7009
|
${review.summary ? `<p class="prb-card-summary">${renderInlineText(review.summary)}</p>` : ""}
|
|
6813
7010
|
<p class="prb-card-where">
|
|
@@ -6824,7 +7021,7 @@ function renderCard(group, now, analysis, queued, moved, prState) {
|
|
|
6824
7021
|
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
6825
7022
|
${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
|
|
6826
7023
|
${renderAnalysisState(analysis)}
|
|
6827
|
-
<span class="prb-card-when"><time datetime="${new Date(
|
|
7024
|
+
<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
7025
|
</p>
|
|
6829
7026
|
</a>
|
|
6830
7027
|
${renderHistory(group, now)}
|
|
@@ -6847,9 +7044,13 @@ function renderCard(group, now, analysis, queued, moved, prState) {
|
|
|
6847
7044
|
*
|
|
6848
7045
|
* The rows underneath carry the per-review menus, and they only exist once there is more than one.
|
|
6849
7046
|
*/
|
|
6850
|
-
function renderCardMenu(group, followed, queued, status) {
|
|
7047
|
+
function renderCardMenu(group, followed, queued, status, activity) {
|
|
6851
7048
|
const count = group.history.length;
|
|
6852
7049
|
const mark = status === "complete" ? "in-progress" : "complete";
|
|
7050
|
+
const lifecycle = {
|
|
7051
|
+
stored: group.latest.lifecycle,
|
|
7052
|
+
activity
|
|
7053
|
+
};
|
|
6853
7054
|
if (count < 2) return renderMenu({
|
|
6854
7055
|
id: group.latest.id,
|
|
6855
7056
|
scope: "review",
|
|
@@ -6858,7 +7059,8 @@ function renderCardMenu(group, followed, queued, status) {
|
|
|
6858
7059
|
question: "Delete this review?",
|
|
6859
7060
|
copy: "Copy review ID",
|
|
6860
7061
|
queue: queued ? "remove" : "add",
|
|
6861
|
-
mark
|
|
7062
|
+
mark,
|
|
7063
|
+
lifecycle
|
|
6862
7064
|
});
|
|
6863
7065
|
const all = `all ${count} reviews following ${followed}`;
|
|
6864
7066
|
return renderMenu({
|
|
@@ -6869,6 +7071,7 @@ function renderCardMenu(group, followed, queued, status) {
|
|
|
6869
7071
|
question: `Delete ${all}?`,
|
|
6870
7072
|
queue: queued ? "remove" : "add",
|
|
6871
7073
|
mark,
|
|
7074
|
+
lifecycle,
|
|
6872
7075
|
copy: "Copy latest review ID"
|
|
6873
7076
|
});
|
|
6874
7077
|
}
|
|
@@ -6888,12 +7091,14 @@ function renderCardMenu(group, followed, queued, status) {
|
|
|
6888
7091
|
function renderMenu(item) {
|
|
6889
7092
|
const id = escapeHtml$1(item.id);
|
|
6890
7093
|
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
|
-
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"
|
|
7094
|
+
const queueItem = item.queue === void 0 ? "" : `<button type="button" class="prb-menu-item" data-queue-action="${item.queue}" data-queue-id="${id}">${queueItemLabel(item.queue === "remove")}</button>`;
|
|
7095
|
+
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
7096
|
return `<div class="prb-menu" data-menu>
|
|
6893
7097
|
<button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(item.label)}">⋯</button>
|
|
6894
7098
|
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
6895
7099
|
<div data-menu-items>
|
|
6896
7100
|
${markItem}
|
|
7101
|
+
${lifecycleItems}
|
|
6897
7102
|
${queueItem}
|
|
6898
7103
|
<button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
|
|
6899
7104
|
<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 +7245,6 @@ function renderRisk(risk) {
|
|
|
7040
7245
|
if (risk === "unknown") return "";
|
|
7041
7246
|
return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
|
|
7042
7247
|
}
|
|
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
7248
|
/**
|
|
7051
7249
|
* Everything the search field matches, flattened into one string on the card.
|
|
7052
7250
|
*
|
|
@@ -7136,6 +7334,115 @@ function escapeHtml(value) {
|
|
|
7136
7334
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7137
7335
|
}
|
|
7138
7336
|
//#endregion
|
|
7337
|
+
//#region ../../packages/review-harness/src/workspace/preferences.ts
|
|
7338
|
+
/**
|
|
7339
|
+
* Workspace preferences that belong to the reviewer rather than to a review.
|
|
7340
|
+
*
|
|
7341
|
+
* Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
|
|
7342
|
+
* origin and this server took whatever port the OS offered on each start, so a restart would
|
|
7343
|
+
* silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
|
|
7344
|
+
* fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
|
|
7345
|
+
* making on its own merits rather than in passing, and there is a second reason it stays for now:
|
|
7346
|
+
* `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
|
|
7347
|
+
* it under them the first time they save, which is why this is not simply a key in there.
|
|
7348
|
+
*
|
|
7349
|
+
* Everything here is best-effort. A workspace that will not open because a preferences file is
|
|
7350
|
+
* unreadable would be a preference costing more than it is worth.
|
|
7351
|
+
*/
|
|
7352
|
+
var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
|
|
7353
|
+
var MAX_ASK_WIDTH = 1200;
|
|
7354
|
+
/** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
|
|
7355
|
+
var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
|
|
7356
|
+
function readPreferences() {
|
|
7357
|
+
try {
|
|
7358
|
+
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
7359
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
7360
|
+
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme } = parsed;
|
|
7361
|
+
return {
|
|
7362
|
+
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
7363
|
+
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
7364
|
+
...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
|
|
7365
|
+
...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
|
|
7366
|
+
...isTheme(theme) ? { theme } : {}
|
|
7367
|
+
};
|
|
7368
|
+
} catch {
|
|
7369
|
+
return {};
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
function writeAskWidth(width) {
|
|
7373
|
+
if (!Number.isFinite(width)) return;
|
|
7374
|
+
try {
|
|
7375
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7376
|
+
const next = {
|
|
7377
|
+
...readPreferences(),
|
|
7378
|
+
askWidth: clampAskWidth(width)
|
|
7379
|
+
};
|
|
7380
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
7381
|
+
} catch {}
|
|
7382
|
+
}
|
|
7383
|
+
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
7384
|
+
function writePromptsCollapsed(collapsed) {
|
|
7385
|
+
try {
|
|
7386
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7387
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7388
|
+
...readPreferences(),
|
|
7389
|
+
promptsCollapsed: collapsed
|
|
7390
|
+
}, null, 2)}\n`);
|
|
7391
|
+
} catch {}
|
|
7392
|
+
}
|
|
7393
|
+
/** Remember which words the change map is read in. Read-modify-write, like every other field here. */
|
|
7394
|
+
function writeChangeMapRegister(register) {
|
|
7395
|
+
try {
|
|
7396
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7397
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7398
|
+
...readPreferences(),
|
|
7399
|
+
changeMapRegister: register
|
|
7400
|
+
}, null, 2)}\n`);
|
|
7401
|
+
} catch {}
|
|
7402
|
+
}
|
|
7403
|
+
/** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
|
|
7404
|
+
function writeTheme(theme) {
|
|
7405
|
+
try {
|
|
7406
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7407
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7408
|
+
...readPreferences(),
|
|
7409
|
+
theme
|
|
7410
|
+
}, null, 2)}\n`);
|
|
7411
|
+
} catch {}
|
|
7412
|
+
}
|
|
7413
|
+
/** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
|
|
7414
|
+
function dismissNotice(key) {
|
|
7415
|
+
if (!NOTICE_KEY.test(key)) return false;
|
|
7416
|
+
try {
|
|
7417
|
+
const current = readPreferences();
|
|
7418
|
+
const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
|
|
7419
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7420
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
7421
|
+
...current,
|
|
7422
|
+
dismissedNotices
|
|
7423
|
+
}, null, 2)}\n`);
|
|
7424
|
+
return true;
|
|
7425
|
+
} catch {
|
|
7426
|
+
return false;
|
|
7427
|
+
}
|
|
7428
|
+
}
|
|
7429
|
+
/** Forget a stated width, so the stylesheet's proportional default applies again. */
|
|
7430
|
+
function clearAskWidth() {
|
|
7431
|
+
try {
|
|
7432
|
+
const { askWidth: _dropped, ...rest } = readPreferences();
|
|
7433
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
7434
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
|
|
7435
|
+
} catch {}
|
|
7436
|
+
}
|
|
7437
|
+
/**
|
|
7438
|
+
* Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
|
|
7439
|
+
* the page enforces because it is the only party that knows how wide the window is — this is the
|
|
7440
|
+
* far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
|
|
7441
|
+
*/
|
|
7442
|
+
function clampAskWidth(width) {
|
|
7443
|
+
return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
|
|
7444
|
+
}
|
|
7445
|
+
//#endregion
|
|
7139
7446
|
//#region ../../packages/review-harness/src/workspace/settings.ts
|
|
7140
7447
|
/**
|
|
7141
7448
|
* The settings the workspace can show and change, as one object rather than as three files.
|
|
@@ -7167,7 +7474,8 @@ function readSettings() {
|
|
|
7167
7474
|
},
|
|
7168
7475
|
agent: readAgentPreference(),
|
|
7169
7476
|
agents: AGENT_IDS,
|
|
7170
|
-
usageCounts: telemetryUploadConsent()
|
|
7477
|
+
usageCounts: telemetryUploadConsent(),
|
|
7478
|
+
theme: readPreferences().theme ?? "system"
|
|
7171
7479
|
};
|
|
7172
7480
|
}
|
|
7173
7481
|
/** True when what the reader is looking at is served on a port they have since changed. */
|
|
@@ -7194,10 +7502,19 @@ function saveSettings(change, minPort, maxPort) {
|
|
|
7194
7502
|
settings: readSettings(),
|
|
7195
7503
|
error: `There is no agent called \`${change.agent}\`.`
|
|
7196
7504
|
};
|
|
7505
|
+
if (change.theme !== void 0 && !isTheme(change.theme)) {
|
|
7506
|
+
const labels = THEMES.map((theme) => theme.label);
|
|
7507
|
+
const choices = `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`;
|
|
7508
|
+
return {
|
|
7509
|
+
settings: readSettings(),
|
|
7510
|
+
error: `There is no theme called \`${change.theme}\`. Choose ${choices}.`
|
|
7511
|
+
};
|
|
7512
|
+
}
|
|
7197
7513
|
if (port !== null) writePortPreference(port);
|
|
7198
7514
|
if (change.agent === "") clearAgentPreference();
|
|
7199
7515
|
else if (change.agent !== void 0) writeAgentPreference(change.agent);
|
|
7200
7516
|
if (change.usageCounts !== void 0) recordTelemetryUploadConsent(change.usageCounts);
|
|
7517
|
+
if (change.theme !== void 0 && isTheme(change.theme)) writeTheme(change.theme);
|
|
7201
7518
|
return {
|
|
7202
7519
|
settings: readSettings(),
|
|
7203
7520
|
error: null
|
|
@@ -7209,7 +7526,7 @@ function renderSettingsPage(view) {
|
|
|
7209
7526
|
const { settings } = view;
|
|
7210
7527
|
const address = `http://localhost:${settings.port.configured}`;
|
|
7211
7528
|
return `<!doctype html>
|
|
7212
|
-
<html lang="en">
|
|
7529
|
+
<html lang="en"${themeAttribute(settings.theme)}>
|
|
7213
7530
|
<head>
|
|
7214
7531
|
<meta charset="utf-8">
|
|
7215
7532
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -7231,6 +7548,7 @@ ${renderWorkspaceRail({
|
|
|
7231
7548
|
unread: view.unreadNotifications ?? 0
|
|
7232
7549
|
})}
|
|
7233
7550
|
<main class="prb-index prb-settings">
|
|
7551
|
+
${renderAppearanceSection(settings)}
|
|
7234
7552
|
${renderWorkspaceSection(settings, address)}
|
|
7235
7553
|
${renderReviewsSection(settings)}
|
|
7236
7554
|
${renderPrivacySection(settings)}
|
|
@@ -7242,7 +7560,8 @@ ${renderWorkspaceRail({
|
|
|
7242
7560
|
|
|
7243
7561
|
<p class="prb-index-note">These are kept in <code>~/.prreviewbuddy/config.json</code>, which you can
|
|
7244
7562
|
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
|
|
7563
|
+
Run <code>prreviewbuddy config path</code> to be told where that file is. Appearance is kept with
|
|
7564
|
+
the workspace's other display choices in <code>~/.prreviewbuddy/ui.json</code>.</p>
|
|
7246
7565
|
|
|
7247
7566
|
${renderServerSection()}
|
|
7248
7567
|
</main>
|
|
@@ -7310,6 +7629,23 @@ function renderWorkspaceSection(settings, address) {
|
|
|
7310
7629
|
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
7630
|
</section>`;
|
|
7312
7631
|
}
|
|
7632
|
+
/**
|
|
7633
|
+
* The theme, as three radios over the registry in theme.ts.
|
|
7634
|
+
*
|
|
7635
|
+
* Choosing one repaints this page at once (see `client/settings.ts`), so the reader can see what
|
|
7636
|
+
* they are picking; Save is still what keeps it, like every other field here.
|
|
7637
|
+
*/
|
|
7638
|
+
function renderAppearanceSection(settings) {
|
|
7639
|
+
return `<section class="prb-settings-section">
|
|
7640
|
+
<h2>Appearance</h2>
|
|
7641
|
+
<div class="prb-field prb-settings-field" role="group" aria-labelledby="prb-theme-label">
|
|
7642
|
+
<span id="prb-theme-label">Theme</span>
|
|
7643
|
+
<div class="prb-settings-choice">
|
|
7644
|
+
${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 ")}
|
|
7645
|
+
</div>
|
|
7646
|
+
</div>
|
|
7647
|
+
</section>`;
|
|
7648
|
+
}
|
|
7313
7649
|
function renderReviewsSection(settings) {
|
|
7314
7650
|
return `<section class="prb-settings-section">
|
|
7315
7651
|
<h2>Reviews</h2>
|
|
@@ -7568,7 +7904,7 @@ function defaultFetchDeps() {
|
|
|
7568
7904
|
function renderNotificationsPage(view) {
|
|
7569
7905
|
const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
|
|
7570
7906
|
return `<!doctype html>
|
|
7571
|
-
<html lang="en">
|
|
7907
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
7572
7908
|
<head>
|
|
7573
7909
|
<meta charset="utf-8">
|
|
7574
7910
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -7765,6 +8101,8 @@ function reviewsRevision(live = analysisStates()) {
|
|
|
7765
8101
|
hash.update(`queue:${queueStamp()}\n`);
|
|
7766
8102
|
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
7767
8103
|
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
8104
|
+
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`);
|
|
8105
|
+
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
8106
|
return hash.digest("base64url");
|
|
7769
8107
|
}
|
|
7770
8108
|
//#endregion
|
|
@@ -8643,104 +8981,6 @@ function readConfiguredEditor() {
|
|
|
8643
8981
|
return null;
|
|
8644
8982
|
}
|
|
8645
8983
|
}
|
|
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
8984
|
/** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
|
|
8745
8985
|
var MAX_PROMPT_CHARS = 2e3;
|
|
8746
8986
|
/**
|
|
@@ -9029,6 +9269,7 @@ async function handle(req, res, options) {
|
|
|
9029
9269
|
if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
|
|
9030
9270
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
9031
9271
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
9272
|
+
if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
|
|
9032
9273
|
if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
|
|
9033
9274
|
if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
|
|
9034
9275
|
if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
|
|
@@ -9055,6 +9296,8 @@ async function handle(req, res, options) {
|
|
|
9055
9296
|
if (action === "/stop") return handleStop(req, res, workspace);
|
|
9056
9297
|
if (action === "/kiss") return handleKiss(req, res, workspace);
|
|
9057
9298
|
if (action === "/state") return handleState(req, res, workspace);
|
|
9299
|
+
if (action === "/lifecycle") return handleOwnLifecycle(req, res, workspace, options.establishBaseline ?? establishBaseline);
|
|
9300
|
+
if (action === "/queue") return handleOwnQueue(req, res, workspace);
|
|
9058
9301
|
if (action === "/delete") return handleDeleteOwn(req, res, workspace);
|
|
9059
9302
|
if (action === "/event") return handleEvent(req, res, workspace);
|
|
9060
9303
|
if (action === "/prefs") return handlePrefs(req, res);
|
|
@@ -9119,9 +9362,11 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
9119
9362
|
if (onward) return redirect(res, onward, cookie);
|
|
9120
9363
|
const states = analysisStates();
|
|
9121
9364
|
const queue = readQueue();
|
|
9122
|
-
const
|
|
9123
|
-
|
|
9124
|
-
const
|
|
9365
|
+
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
9366
|
+
const kept = indexKeptIds(reviews, queue);
|
|
9367
|
+
const everyGroup = groupByLineage(reviews);
|
|
9368
|
+
const activity = reviewActivity(everyGroup);
|
|
9369
|
+
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup);
|
|
9125
9370
|
const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
|
|
9126
9371
|
sendHtml(res, 200, renderReviewsPage({
|
|
9127
9372
|
groups,
|
|
@@ -9129,9 +9374,11 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
9129
9374
|
analyses: states,
|
|
9130
9375
|
moved: movedReviews(),
|
|
9131
9376
|
prStates: prStatesByRequest(),
|
|
9377
|
+
activity,
|
|
9132
9378
|
revision: reviewsRevision(states),
|
|
9133
9379
|
unreadNotifications: unreadCount$1(),
|
|
9134
|
-
driving: isDriving()
|
|
9380
|
+
driving: isDriving(),
|
|
9381
|
+
theme: readPreferences().theme
|
|
9135
9382
|
}), cookie);
|
|
9136
9383
|
}
|
|
9137
9384
|
/**
|
|
@@ -9203,7 +9450,8 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
9203
9450
|
const { settings, error } = saveSettings({
|
|
9204
9451
|
...typeof body?.port === "string" ? { port: body.port } : {},
|
|
9205
9452
|
...typeof body?.agent === "string" ? { agent: body.agent } : {},
|
|
9206
|
-
...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {}
|
|
9453
|
+
...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {},
|
|
9454
|
+
...typeof body?.theme === "string" ? { theme: body.theme } : {}
|
|
9207
9455
|
}, MIN_WORKSPACE_PORT, MAX_WORKSPACE_PORT);
|
|
9208
9456
|
return send(res, 200, {
|
|
9209
9457
|
port: {
|
|
@@ -9212,6 +9460,7 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
9212
9460
|
},
|
|
9213
9461
|
agent: settings.agent,
|
|
9214
9462
|
usageCounts: settings.usageCounts,
|
|
9463
|
+
theme: settings.theme,
|
|
9215
9464
|
restartRequired: restartRequired(settings),
|
|
9216
9465
|
error
|
|
9217
9466
|
});
|
|
@@ -9303,7 +9552,8 @@ async function handleNotifications(req, res, params, indexToken) {
|
|
|
9303
9552
|
notifications,
|
|
9304
9553
|
gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
|
|
9305
9554
|
unreadNotifications: unreadCount$1(),
|
|
9306
|
-
driving: isDriving()
|
|
9555
|
+
driving: isDriving(),
|
|
9556
|
+
theme: readPreferences().theme
|
|
9307
9557
|
}), indexCookieHeader(indexToken));
|
|
9308
9558
|
}
|
|
9309
9559
|
/**
|
|
@@ -9354,7 +9604,6 @@ async function handleStatus(req, res, indexToken) {
|
|
|
9354
9604
|
current.completedAt = complete ? now : void 0;
|
|
9355
9605
|
if (!complete) current.lastViewedAt = current.lastViewedAt ?? now;
|
|
9356
9606
|
})) continue;
|
|
9357
|
-
dequeueOnComplete(id, complete);
|
|
9358
9607
|
changed += 1;
|
|
9359
9608
|
}
|
|
9360
9609
|
send(res, 200, {
|
|
@@ -9363,6 +9612,156 @@ async function handleStatus(req, res, indexToken) {
|
|
|
9363
9612
|
});
|
|
9364
9613
|
}
|
|
9365
9614
|
/**
|
|
9615
|
+
* Put a selection in Active, Waiting or Done.
|
|
9616
|
+
*
|
|
9617
|
+
* The same shape and the same credential as `handleStatus` beside it, for its reasons: this is the
|
|
9618
|
+
* list acting on itself, and a lifecycle is a note about what happens next, not a change to the
|
|
9619
|
+
* analysis, so nothing in flight is a reason to refuse it.
|
|
9620
|
+
*
|
|
9621
|
+
* **A state that is stated and unknown is refused whole**, naming it and what would have worked.
|
|
9622
|
+
* Guessing (reading `archived` as Done, say) would put cards where the reviewer did not ask for
|
|
9623
|
+
* them, and skipping it would answer 200 to a request that did nothing.
|
|
9624
|
+
*
|
|
9625
|
+
* Waiting and Done both leave the queue: it holds work somebody means to do next, and either mark
|
|
9626
|
+
* says there is none. Active does not put anything back in, as un-marking a review does not.
|
|
9627
|
+
*
|
|
9628
|
+
* Marking Waiting again writes a new `at`, which is what settles a Waiting review that changed: the
|
|
9629
|
+
* reviewer has seen the change and parked it again.
|
|
9630
|
+
*/
|
|
9631
|
+
async function handleLifecycle(req, res, indexToken, baseline) {
|
|
9632
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
9633
|
+
const body = await readJson(req);
|
|
9634
|
+
const state = body?.state;
|
|
9635
|
+
if (!isLifecycle(state)) {
|
|
9636
|
+
const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
|
|
9637
|
+
return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
|
|
9638
|
+
}
|
|
9639
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string") : [];
|
|
9640
|
+
const { changed, at } = applyLifecycle(ids, state);
|
|
9641
|
+
send(res, 200, {
|
|
9642
|
+
ok: true,
|
|
9643
|
+
changed,
|
|
9644
|
+
at,
|
|
9645
|
+
queue: readQueue()
|
|
9646
|
+
});
|
|
9647
|
+
baselineParked(ids, state, baseline);
|
|
9648
|
+
}
|
|
9649
|
+
/**
|
|
9650
|
+
* The write behind every lifecycle mark, from the list or from the review's own page. Waiting and
|
|
9651
|
+
* Done dequeue; see `handleLifecycle`.
|
|
9652
|
+
*/
|
|
9653
|
+
function applyLifecycle(ids, state) {
|
|
9654
|
+
const at = Date.now();
|
|
9655
|
+
let changed = 0;
|
|
9656
|
+
for (const id of ids) {
|
|
9657
|
+
if (!mutateWorkspace(id, (current) => {
|
|
9658
|
+
current.lifecycle = state === "active" ? void 0 : {
|
|
9659
|
+
state,
|
|
9660
|
+
at
|
|
9661
|
+
};
|
|
9662
|
+
})) continue;
|
|
9663
|
+
if (state !== "active") queueRemove(id);
|
|
9664
|
+
changed += 1;
|
|
9665
|
+
}
|
|
9666
|
+
return {
|
|
9667
|
+
changed,
|
|
9668
|
+
at
|
|
9669
|
+
};
|
|
9670
|
+
}
|
|
9671
|
+
/** After the answer is sent, so a first look at the forge never holds up the page that asked. */
|
|
9672
|
+
function baselineParked(ids, state, baseline) {
|
|
9673
|
+
if (state !== "waiting") return;
|
|
9674
|
+
const unseen = unobserved(ids);
|
|
9675
|
+
if (unseen.size > 0) baseline(unseen);
|
|
9676
|
+
}
|
|
9677
|
+
/**
|
|
9678
|
+
* Lifecycle and queue, asked by the review's own page on its own token.
|
|
9679
|
+
*
|
|
9680
|
+
* Both belong to the branch, and the list only ever reads them from the newest review of it, so a
|
|
9681
|
+
* mark written on an older one would be a write nobody sees. The page offers neither there; this
|
|
9682
|
+
* says why to anything that asks anyway.
|
|
9683
|
+
*/
|
|
9684
|
+
function refuseUnlessLatest(res, workspace) {
|
|
9685
|
+
const { position, total } = lineagePosition(workspace);
|
|
9686
|
+
if (position >= total) return false;
|
|
9687
|
+
send(res, 409, { error: "A newer review of this branch exists. Waiting, Done and the review queue are set on the newest one." });
|
|
9688
|
+
return true;
|
|
9689
|
+
}
|
|
9690
|
+
/**
|
|
9691
|
+
* What the Reviews list would say about this branch: the newest analysis's creation, a push the
|
|
9692
|
+
* watcher saw, or a change of pull request state. Reads every record, so it is asked only where
|
|
9693
|
+
* it can change what is drawn.
|
|
9694
|
+
*/
|
|
9695
|
+
function branchActivity(workspace, all = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
|
|
9696
|
+
const group = groupByLineage(all).find((candidate) => candidate.history.some((review) => review.id === workspace.id));
|
|
9697
|
+
return group ? reviewActivity([group]).get(group.latest.id) : void 0;
|
|
9698
|
+
}
|
|
9699
|
+
/**
|
|
9700
|
+
* Where the review stands, for the page's own checks to repaint from. Another tab can mark or park
|
|
9701
|
+
* it, and the watcher can see the branch move while the page is open.
|
|
9702
|
+
*
|
|
9703
|
+
* Activity only for a parked review: it decides nothing for an Active one, and it costs a read of
|
|
9704
|
+
* every record (measured 27 ms warm, 525 ms cold, on 66 reviews), once a minute per open tab.
|
|
9705
|
+
*/
|
|
9706
|
+
function pageStanding(workspace) {
|
|
9707
|
+
const lifecycle = parseLifecycle(workspace.lifecycle) ?? null;
|
|
9708
|
+
return {
|
|
9709
|
+
complete: workspace.completedAt !== void 0,
|
|
9710
|
+
lifecycle,
|
|
9711
|
+
queued: readQueue().includes(workspace.id),
|
|
9712
|
+
activity: lifecycle ? branchActivity(workspace) ?? null : null
|
|
9713
|
+
};
|
|
9714
|
+
}
|
|
9715
|
+
/** Where this review stands after a lifecycle or queue change, which is everything the page repaints. */
|
|
9716
|
+
function ownStanding(id) {
|
|
9717
|
+
return {
|
|
9718
|
+
lifecycle: parseLifecycle(loadWorkspace(id)?.lifecycle) ?? null,
|
|
9719
|
+
queued: readQueue().includes(id)
|
|
9720
|
+
};
|
|
9721
|
+
}
|
|
9722
|
+
async function handleOwnLifecycle(req, res, workspace, baseline) {
|
|
9723
|
+
const state = (await readJson(req))?.state;
|
|
9724
|
+
if (!isLifecycle(state)) {
|
|
9725
|
+
const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
|
|
9726
|
+
return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
|
|
9727
|
+
}
|
|
9728
|
+
if (refuseUnlessLatest(res, workspace)) return;
|
|
9729
|
+
applyLifecycle([workspace.id], state);
|
|
9730
|
+
send(res, 200, {
|
|
9731
|
+
ok: true,
|
|
9732
|
+
...ownStanding(workspace.id)
|
|
9733
|
+
});
|
|
9734
|
+
baselineParked([workspace.id], state, baseline);
|
|
9735
|
+
}
|
|
9736
|
+
async function handleOwnQueue(req, res, workspace) {
|
|
9737
|
+
const action = (await readJson(req))?.action;
|
|
9738
|
+
if (action !== "add" && action !== "remove") return send(res, 400, { error: "Unknown queue action." });
|
|
9739
|
+
if (refuseUnlessLatest(res, workspace)) return;
|
|
9740
|
+
const stored = applyQueue(workspace.id, action);
|
|
9741
|
+
if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
|
|
9742
|
+
if (action === "add" && !stored.includes(workspace.id)) return send(res, 409, { error: `The review queue is full. It holds 50 reviews at most.` });
|
|
9743
|
+
send(res, 200, ownStanding(workspace.id));
|
|
9744
|
+
}
|
|
9745
|
+
/**
|
|
9746
|
+
* Which of these reviews the watcher has never seen the head of.
|
|
9747
|
+
*
|
|
9748
|
+
* Those are the ones Waiting cannot yet work for: a first observation is not a change (it says
|
|
9749
|
+
* nothing about when the head moved), so a push before it would be absorbed as the baseline. A
|
|
9750
|
+
* review already observed has its baseline, and polling it again now would only spend a request.
|
|
9751
|
+
*/
|
|
9752
|
+
function unobserved(ids) {
|
|
9753
|
+
const seen = new Set(Object.values(readWatch()).filter((entry) => entry.observedSha !== void 0).map((entry) => entry.reviewId));
|
|
9754
|
+
return new Set(ids.filter((id) => !seen.has(id)));
|
|
9755
|
+
}
|
|
9756
|
+
/**
|
|
9757
|
+
* The first look itself, by the watcher's own rules. A review that cannot be observed (the forge is
|
|
9758
|
+
* unreachable, or it is a commit review with nothing to watch) stays Waiting with no baseline, and
|
|
9759
|
+
* no change is invented for it: the next successful poll sets one.
|
|
9760
|
+
*/
|
|
9761
|
+
function establishBaseline(reviewIds) {
|
|
9762
|
+
watchAllNow(reviewIds).catch(() => {});
|
|
9763
|
+
}
|
|
9764
|
+
/**
|
|
9366
9765
|
* Has anything the reviews index draws changed, and is anything still being made.
|
|
9367
9766
|
*
|
|
9368
9767
|
* The whole of the index's liveness, in one request that costs the same whether the machine holds
|
|
@@ -9454,10 +9853,17 @@ async function handleQueue(req, res, indexToken) {
|
|
|
9454
9853
|
return addMany(res, body.ids.filter((one) => typeof one === "string"));
|
|
9455
9854
|
}
|
|
9456
9855
|
if (!id || !loadWorkspace(id)) return send(res, 404, { error: "No such review" });
|
|
9856
|
+
const stored = applyQueue(id, action);
|
|
9857
|
+
if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
|
|
9858
|
+
send(res, 200, { queue: stored });
|
|
9859
|
+
}
|
|
9860
|
+
/** One review in or out of the queue, from a card or from its own page. The queue as stored, or null. */
|
|
9861
|
+
function applyQueue(id, action) {
|
|
9457
9862
|
if (action === "add") compactQueue(readQueue());
|
|
9458
9863
|
const stored = action === "add" ? queueAdd(id) : queueRemove(id);
|
|
9459
|
-
if (stored === null) return
|
|
9460
|
-
|
|
9864
|
+
if (stored === null) return null;
|
|
9865
|
+
if (action === "add") activateQueued([id], stored);
|
|
9866
|
+
return compactQueue(stored);
|
|
9461
9867
|
}
|
|
9462
9868
|
/**
|
|
9463
9869
|
* Adding a selection, and saying which of it did not go in.
|
|
@@ -9473,6 +9879,7 @@ function addMany(res, ids) {
|
|
|
9473
9879
|
compactQueue(readQueue());
|
|
9474
9880
|
const outcome = queueAddMany(known);
|
|
9475
9881
|
if (outcome === null) return send(res, 500, { error: "The queue could not be saved." });
|
|
9882
|
+
activateQueued(known, outcome.queue);
|
|
9476
9883
|
const refused = [...outcome.full.map((one) => ({
|
|
9477
9884
|
id: one,
|
|
9478
9885
|
error: `The review queue is full. It holds 50 reviews at most.`
|
|
@@ -9698,6 +10105,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
9698
10105
|
if (notice) mutateWorkspace(workspace.id, (current) => {
|
|
9699
10106
|
if (current.lastUpdate?.at === notice.at) current.lastUpdate.acknowledged = true;
|
|
9700
10107
|
});
|
|
10108
|
+
const everyReview = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
9701
10109
|
const html = renderWorkspace({
|
|
9702
10110
|
session: workspace.session,
|
|
9703
10111
|
changeSet: workspace.changeSet,
|
|
@@ -9709,6 +10117,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
9709
10117
|
editor: editorChoice(),
|
|
9710
10118
|
repoPath: workspace.repoPath,
|
|
9711
10119
|
askWidth: readPreferences().askWidth,
|
|
10120
|
+
theme: readPreferences().theme,
|
|
9712
10121
|
dismissedNotices: readPreferences().dismissedNotices,
|
|
9713
10122
|
promptsCollapsed: readPreferences().promptsCollapsed === true,
|
|
9714
10123
|
changeMapRegister: readPreferences().changeMapRegister ?? "simple",
|
|
@@ -9735,7 +10144,10 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
9735
10144
|
changeMap: workspace.changeMap,
|
|
9736
10145
|
reviewedSha: reviewedCommit(workspace),
|
|
9737
10146
|
revision: workspaceRevision(workspace),
|
|
9738
|
-
lineage: lineagePosition(workspace),
|
|
10147
|
+
lineage: lineagePosition(workspace, everyReview),
|
|
10148
|
+
lifecycle: parseLifecycle(workspace.lifecycle),
|
|
10149
|
+
activity: branchActivity(workspace, everyReview),
|
|
10150
|
+
queued: readQueue().includes(workspace.id),
|
|
9739
10151
|
job: workspace.jobId ? loadJob(workspace.jobId) : void 0,
|
|
9740
10152
|
updateJob: liveUpdateFor(workspace.id),
|
|
9741
10153
|
kissRun,
|
|
@@ -9855,7 +10267,8 @@ async function handleFreshness(res, workspace, quiet) {
|
|
|
9855
10267
|
prState,
|
|
9856
10268
|
kiss,
|
|
9857
10269
|
kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
|
|
9858
|
-
revision
|
|
10270
|
+
revision,
|
|
10271
|
+
standing: pageStanding(workspace)
|
|
9859
10272
|
});
|
|
9860
10273
|
if (!quiet) record({
|
|
9861
10274
|
event: "freshness_checked",
|
|
@@ -9872,7 +10285,8 @@ async function handleFreshness(res, workspace, quiet) {
|
|
|
9872
10285
|
dismissedSha: workspace.dismissedFreshnessSha ?? null,
|
|
9873
10286
|
kiss,
|
|
9874
10287
|
kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
|
|
9875
|
-
revision
|
|
10288
|
+
revision,
|
|
10289
|
+
standing: pageStanding(workspace)
|
|
9876
10290
|
});
|
|
9877
10291
|
}
|
|
9878
10292
|
/**
|
|
@@ -10330,7 +10744,8 @@ async function handleState(req, res, workspace) {
|
|
|
10330
10744
|
send(res, 200, {
|
|
10331
10745
|
ok: true,
|
|
10332
10746
|
completedAt: saved.completedAt ?? null,
|
|
10333
|
-
pinned: saved.pinned === true
|
|
10747
|
+
pinned: saved.pinned === true,
|
|
10748
|
+
queued: readQueue().includes(saved.id)
|
|
10334
10749
|
});
|
|
10335
10750
|
}
|
|
10336
10751
|
/** What `/state` can change. A body naming none of these writes nothing. */
|
|
@@ -10348,10 +10763,7 @@ function applyState(workspace, body) {
|
|
|
10348
10763
|
if (Array.isArray(body?.dismissedIssues)) reviewer.dismissedIssues = body.dismissedIssues.filter((entry) => typeof entry?.id === "string" && typeof entry?.sessionId === "string");
|
|
10349
10764
|
if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
|
|
10350
10765
|
if (typeof body?.dismissKissFreshnessSha === "string" || body?.dismissKissFreshnessSha === null) workspace.dismissedKissFreshnessSha = body.dismissKissFreshnessSha ?? void 0;
|
|
10351
|
-
if (typeof body?.complete === "boolean")
|
|
10352
|
-
workspace.completedAt = body.complete ? Date.now() : void 0;
|
|
10353
|
-
dequeueOnComplete(workspace.id, body.complete);
|
|
10354
|
-
}
|
|
10766
|
+
if (typeof body?.complete === "boolean") workspace.completedAt = body.complete ? Date.now() : void 0;
|
|
10355
10767
|
if (typeof body?.pinned === "boolean") workspace.pinned = body.pinned;
|
|
10356
10768
|
}
|
|
10357
10769
|
/** 1 MB is far more than any question or id list, and far less than a memory problem. */
|