prreviewbuddy 0.81.0 → 0.82.1
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 +33 -0
- package/dist/{delete_review-CFVFygqb.js → delete_review-BED4mPxB.js} +333 -23
- package/dist/main.js +1 -1
- package/dist/server.js +554 -36
- package/package.json +1 -1
- package/static/chunk-CQNkZ9TG.js +1 -0
- package/static/{chunk-DnOyRP8m.js → chunk-CWRKWlhO.js} +1 -1
- package/static/{chunk-C3E91izb.js → chunk-CxFmUaBj.js} +1 -1
- package/static/help.js +1 -1
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/runs.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +128 -0
- package/static/workspace.js +79 -79
- package/static/chunk-C8d9FvLk.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as MIN_WORKSPACE_PORT, $i as
|
|
1
|
+
import { $ as MIN_WORKSPACE_PORT, $i as resolveModel, $n as RUN_RECORD_RETENTION_MS, $r as deleteCollection, $t as markRead, A as batchIdentity, Ai as isGradeFor, An as escapeHtml$1, At as loadArchitecture, Bi as WITHHELD_REASON, Bn as readMarker, Br as LIFECYCLES, Bt as ancestorsOf, C as RUN_KINDS, Ci as ANALYSIS_ONLY, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as findingAssessment, Dn as renderMarkdown, Dr as loadWorkspace, Dt as formatDuration, E as endRun, Ei as currentGradeIn, En as GITHUB_MARKDOWN, Er as lineagePosition, Et as describeRuntimes, F as activateQueued, Fi as followUpFindingId, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as isCommitReview, Gn as findExistingReview, Gr as lifecycleItemLabel, Gt as subjectOf, Hi as resolveTarget, Hn as requestJobStop, Hr as activityLabel, Ht as writeJsonAtomically, I as compactQueue, Ii as asString, In as MANAGED_ROOT, Ir as summarise, It as trackedFiles, Ji as shortNameOf, Jn as followedBranch, Jr as parseLifecycle, Jt as createPrState, K as spawnServer, Ki as resolveBase, Kn as checkCodeFreshness, Kr as lifecyclePill, Kt as categoryOf, Li as extractJsonText, Ln as createWorktree, Lr as touchWorkspace, Lt as ownersOf, M as parsePullRequestLink, Mi as reassessIn, Mr as previousKissResult, Mt as readArchitecture, N as unsupportedReason, Ni as sourcePhrase, Nn as ReviewBeingDeletedError, Nr as recentWorkspaces, Nt as repositoryKey, O as stopRun, Oi as findingIn, On as CODE_BLOCK_CLASS, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as wasReassessed, Pn as isBusy, Pr as reviewedCommit, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as resolveAgent, Qn as fillFileUrlTemplate, Qr as createWithMembers, Qt as markAllRead, R as settleQueue, Ri as objectWithAnyOf, Rn as holdWorktree, Rr as workspaceRevision, Rt as QUOTE_LIMITS, S as track, Si as withExplanation, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as assessmentLookup, Tn as hasAuthorContent, Tr as lineageKey, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as originRemote, Ur as isLifecycle, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as kissOutcome, Vn as removeWorktree, Vr as activityAttributes, Vt as isArchitectureId, Wi as DetachedHeadError, Wr as lifecycleChip, Wt as branchOf, X as writeServerRecord, Xi as git, Xn as fetchHead, Xr as collectionsStamp, Xt as eventName, Yi as shorthandOf, Yn as reviewedShaOf, Yr as sectionOf, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as refExists, Zn as forgeResolver, Zr as createCollection, Zt as listNotifications, _i as processDiscussion, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as STORE_ROOT, ai as queueAdd, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, bi as explanationOf, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ca as agentFor$1, ci as queueStamp, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, da as isAgentId, di as isIssueOutstanding, dn as existingPaths, dr as phasesFor, ea as agentIdOf, ei as nameProblemText, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fa as parkedReason, fi as isQuestionOutstanding, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, gi as reviewerDispositions, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, ha as AgentUnavailableError, hi as resolvedByConversation, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ii as updateCollections, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as promptGrade, jn as renderInlineText, jr as presentedKissRunOf, jt as partFiles, k as relativeTime$1, ki as gradeScale, kn as CODE_COPY_CLASS, kr as mutateWorkspace, kt as checkArchitecture, l as cancelUpdate, la as agentIdentityOf, li as readQueue, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, ma as AgentCancelledError, mi as questionsOutstanding, mn as prepareConversationWithin, n as deleteReview, na as readAgentPreference, ni as renameCollection, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oa as AGENT_IDS, oi as queueAddMany, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pa as runnableAgents, pi as issuesOutstanding, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as AmbiguousRefError, qn as checkReview, qr as offersLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as writeAgentPreference, ri as setMembership, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, sa as agentById, si as queueRemove, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as clearAgentPreference, ti as readCollections, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ua as detectAgents, ui as doneVerb, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, vi as recommendationsOf, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as analysisRefOf, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xi as isExplanationAction, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, yi as explanationActionOf, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as describeAuthorship, zn as isWorktreeHeld, zr as workspaceStamps, zt as REPOSITORY_SCOPE } from "./delete_review-BED4mPxB.js";
|
|
2
2
|
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -282,15 +282,37 @@ var INDEX_SECTION_NAMES = {
|
|
|
282
282
|
};
|
|
283
283
|
/**
|
|
284
284
|
* Whether a section starts open. The reviewer's choice wins, and otherwise everything is open but
|
|
285
|
-
* Done,
|
|
285
|
+
* the archives: Done, and each collection's completed reviews. A new collection starts open. The
|
|
286
|
+
* one place that default is written.
|
|
286
287
|
*/
|
|
287
288
|
function sectionOpen(section, collapsed) {
|
|
288
|
-
return !(collapsed?.[section] ?? section === "done");
|
|
289
|
+
return !(collapsed?.[section] ?? (section === "done" || section.startsWith("collection-done:")));
|
|
289
290
|
}
|
|
290
291
|
/** A section the list draws a heading for, and so one whose fold can be stored. */
|
|
291
292
|
function isListSection(value) {
|
|
292
293
|
return typeof value === "string" && LIST_SECTIONS.includes(value);
|
|
293
294
|
}
|
|
295
|
+
/** A fold the store accepts: a list section, or a collection's, by an id shaped like one. */
|
|
296
|
+
function isFoldKey(value) {
|
|
297
|
+
if (isListSection(value)) return true;
|
|
298
|
+
if (typeof value !== "string") return false;
|
|
299
|
+
const match = /^collection(?:-done)?:(.*)$/.exec(value);
|
|
300
|
+
return match !== null && isCollectionId(match[1]);
|
|
301
|
+
}
|
|
302
|
+
function collectionFold(id) {
|
|
303
|
+
return `collection:${id}`;
|
|
304
|
+
}
|
|
305
|
+
function completedFold(id) {
|
|
306
|
+
return `collection-done:${id}`;
|
|
307
|
+
}
|
|
308
|
+
var COLLECTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
309
|
+
/**
|
|
310
|
+
* Whether a value is shaped like a collection's id. Here, in the module both halves share, because
|
|
311
|
+
* the hash and the fold store check it in the browser and on the server alike.
|
|
312
|
+
*/
|
|
313
|
+
function isCollectionId(value) {
|
|
314
|
+
return typeof value === "string" && COLLECTION_ID.test(value);
|
|
315
|
+
}
|
|
294
316
|
/**
|
|
295
317
|
* Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
|
|
296
318
|
*
|
|
@@ -302,6 +324,69 @@ function indexSectionOf(pinned, lifecycle, activity, filter) {
|
|
|
302
324
|
return pinned && !ordersExplicitly(filter) ? "pinned" : sectionOf(lifecycle, activity);
|
|
303
325
|
}
|
|
304
326
|
/**
|
|
327
|
+
* Where a review's own card is drawn, or absent when it is drawn only inside its collections.
|
|
328
|
+
*
|
|
329
|
+
* Pinned first and exclusive, as it always was: a pinned review is drawn in Pinned and nowhere else,
|
|
330
|
+
* collected or not, and unpinning it puts it back where the rest of these rules say. A review in a
|
|
331
|
+
* collection is drawn there instead of in Active, Waiting or Done, so those three list what nobody
|
|
332
|
+
* has grouped. Under the Queue filter neither applies: the list is the queue in its order, each card
|
|
333
|
+
* in its lifecycle section, the same reason `indexSectionOf` gives for Pinned.
|
|
334
|
+
*/
|
|
335
|
+
function homeSection(pinned, lifecycle, activity, collected, filter) {
|
|
336
|
+
if (collected && !pinned && !ordersExplicitly(filter)) return void 0;
|
|
337
|
+
return indexSectionOf(pinned, lifecycle, activity, filter);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Where a review is drawn inside each collection it belongs to: with the collection's reviews still
|
|
341
|
+
* needing attention, under its completed reviews, or not at all.
|
|
342
|
+
*
|
|
343
|
+
* Completed means Done, by the same derivation that places the card everywhere else, so Waiting is
|
|
344
|
+
* still in play and the Reviewed mark has nothing to do with it. One state for every collection the
|
|
345
|
+
* review is in: marking it Done moves it in all of them at once. Not drawn while pinned (Pinned is
|
|
346
|
+
* exclusive) or under the Queue filter (see `homeSection`).
|
|
347
|
+
*/
|
|
348
|
+
function collectionPlacement(pinned, lifecycle, activity, filter) {
|
|
349
|
+
if (pinned || ordersExplicitly(filter)) return void 0;
|
|
350
|
+
return sectionOf(lifecycle, activity) === "done" ? "completed" : "active";
|
|
351
|
+
}
|
|
352
|
+
/** A collection heading's count: what still needs attention, and the members drawn in Pinned. */
|
|
353
|
+
function collectionCountLabel(active, pinned) {
|
|
354
|
+
const label = `${active} active`;
|
|
355
|
+
return pinned === 0 ? label : `${label} · ${pinned} pinned`;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The quiet line on a card naming its collections, or absent when it would say nothing new.
|
|
359
|
+
*
|
|
360
|
+
* Inside a collection the heading already names that one, so the line says only where else the
|
|
361
|
+
* review is ("Also in Release 0.82"), and nothing for a review in one collection. Outside every
|
|
362
|
+
* collection heading, which is Pinned and the Queue filter, it names them all.
|
|
363
|
+
*/
|
|
364
|
+
function collectionsLine(memberOf, collections, current) {
|
|
365
|
+
const names = collections.filter((collection) => collection.id !== current && memberOf.includes(collection.id)).map((collection) => collection.name);
|
|
366
|
+
if (names.length === 0) return void 0;
|
|
367
|
+
return `${current === void 0 ? "In" : "Also in"} ${names.join(", ")}`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* What a collection says when its main list is empty, or absent when it is not. A collection is
|
|
371
|
+
* never hidden for having nothing active: it is still somewhere its finished reviews live, and
|
|
372
|
+
* somewhere to put the next one.
|
|
373
|
+
*/
|
|
374
|
+
function collectionEmptyText(counts, filtered) {
|
|
375
|
+
if (counts.active > 0) return void 0;
|
|
376
|
+
if (filtered) return "Nothing in this collection matches the filters.";
|
|
377
|
+
if (counts.members === 0) return "Nothing here yet. Add reviews from a card’s menu, or select several and choose Manage collections.";
|
|
378
|
+
if (counts.pinned > 0 && counts.completed === 0) return "Its reviews are pinned, so they are listed under Pinned.";
|
|
379
|
+
return "Nothing here needs attention.";
|
|
380
|
+
}
|
|
381
|
+
/** Said under a collection whose oldest members are not drawn, so it never looks smaller than it is. */
|
|
382
|
+
function omittedText(omitted) {
|
|
383
|
+
return omitted === 1 ? "1 older review in this collection is not listed." : `${omitted} older reviews in this collection are not listed.`;
|
|
384
|
+
}
|
|
385
|
+
/** The disclosure holding a collection's Done reviews. */
|
|
386
|
+
function completedLabel(completed) {
|
|
387
|
+
return `Show completed (${completed})`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
305
390
|
* Whether a filter replaces the activity order with one the reviewer set. Queue is the only one:
|
|
306
391
|
* the list is the queue, in its order.
|
|
307
392
|
* `orderIndex` and `indexSectionOf` both ask this, so the ordering and the placement cannot disagree.
|
|
@@ -9533,27 +9618,63 @@ function operationLine(operation) {
|
|
|
9533
9618
|
}
|
|
9534
9619
|
//#endregion
|
|
9535
9620
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
9536
|
-
function
|
|
9621
|
+
function indexContext(view) {
|
|
9537
9622
|
const now = view.now ?? Date.now();
|
|
9538
9623
|
const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
|
|
9539
9624
|
at: group.latest.createdAt,
|
|
9540
9625
|
kind: "created"
|
|
9541
9626
|
};
|
|
9542
9627
|
const ordered = [...view.groups].sort((a, b) => activityOf(b).at - activityOf(a).at);
|
|
9628
|
+
const rank = new Map(ordered.map((group, index) => [group, index]));
|
|
9629
|
+
const operations = view.operations ?? [];
|
|
9630
|
+
const inProgressOnly = new Set(ordered.filter((group) => group.history.length === 1 && !group.latest.hasStandardReview && operations.some((operation) => operation.reviewId === group.latest.id && operation.purpose !== "update")).map((group) => group.latest.id));
|
|
9631
|
+
const queue = view.queue ?? [];
|
|
9632
|
+
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
9633
|
+
const collections = view.collections ?? [];
|
|
9634
|
+
const refs = collections.map(({ id, name }) => ({
|
|
9635
|
+
id,
|
|
9636
|
+
name
|
|
9637
|
+
}));
|
|
9638
|
+
const memberOf = membershipsByLineage(collections);
|
|
9639
|
+
const renderCards = (groups, placement, hidden = () => false) => 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), inProgressOnly.has(group.latest.id), {
|
|
9640
|
+
placement,
|
|
9641
|
+
memberOf: memberOf.get(group.key) ?? [],
|
|
9642
|
+
refs,
|
|
9643
|
+
hidden: hidden(group)
|
|
9644
|
+
})).join("");
|
|
9645
|
+
return {
|
|
9646
|
+
now,
|
|
9647
|
+
ordered,
|
|
9648
|
+
activityOf,
|
|
9649
|
+
inProgressOnly,
|
|
9650
|
+
positions,
|
|
9651
|
+
queue,
|
|
9652
|
+
refs,
|
|
9653
|
+
collections,
|
|
9654
|
+
memberOf,
|
|
9655
|
+
renderCards
|
|
9656
|
+
};
|
|
9657
|
+
}
|
|
9658
|
+
function renderReviewsPage(view) {
|
|
9659
|
+
const context = indexContext(view);
|
|
9660
|
+
const { ordered, activityOf, inProgressOnly, positions, queue, memberOf, renderCards } = context;
|
|
9661
|
+
const now = context.now;
|
|
9662
|
+
const operations = view.operations ?? [];
|
|
9543
9663
|
const sections = {
|
|
9544
9664
|
pinned: [],
|
|
9545
9665
|
active: [],
|
|
9546
9666
|
waiting: [],
|
|
9547
9667
|
done: []
|
|
9548
9668
|
};
|
|
9549
|
-
|
|
9550
|
-
const
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9669
|
+
const collectedHome = /* @__PURE__ */ new Set();
|
|
9670
|
+
for (const group of ordered) {
|
|
9671
|
+
const collected = (memberOf.get(group.key) ?? []).length > 0;
|
|
9672
|
+
const home = homeSection(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), collected, "all");
|
|
9673
|
+
if (home === void 0) collectedHome.add(group);
|
|
9674
|
+
sections[home ?? sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
|
|
9675
|
+
}
|
|
9554
9676
|
const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
|
|
9555
|
-
const
|
|
9556
|
-
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
9677
|
+
const renderSectionAt = (section) => renderSection(section, sections[section], (groups) => renderCards(groups, "home", (group) => collectedHome.has(group)), sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id) && !collectedHome.has(group)).length);
|
|
9557
9678
|
return `<!doctype html>
|
|
9558
9679
|
<html lang="en"${themeAttribute(view.theme)}>
|
|
9559
9680
|
<head>
|
|
@@ -9564,7 +9685,7 @@ function renderReviewsPage(view) {
|
|
|
9564
9685
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
9565
9686
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
9566
9687
|
</head>
|
|
9567
|
-
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
|
|
9688
|
+
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`}${view.snapshot === void 0 ? "" : ` data-cards-snapshot="${escapeHtml$1(view.snapshot)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
|
|
9568
9689
|
${renderGlobalBar({
|
|
9569
9690
|
current: "reviews",
|
|
9570
9691
|
unread: view.unreadNotifications ?? 0,
|
|
@@ -9574,7 +9695,7 @@ ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls$1(queue
|
|
|
9574
9695
|
handle: latest.authorHandle,
|
|
9575
9696
|
name: latest.authorName,
|
|
9576
9697
|
createdAt: latest.createdAt
|
|
9577
|
-
}))), ordered.some((group) => group.latest.prNumber !== void 0)))}
|
|
9698
|
+
}))), ordered.some((group) => group.latest.prNumber !== void 0), context.refs.length > 0))}
|
|
9578
9699
|
<div class="prb-shell">
|
|
9579
9700
|
${renderWorkspaceRail({
|
|
9580
9701
|
current: "reviews",
|
|
@@ -9585,11 +9706,14 @@ ${renderWorkspaceRail({
|
|
|
9585
9706
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
9586
9707
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
9587
9708
|
</div>`}
|
|
9588
|
-
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9589
|
-
${
|
|
9709
|
+
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed), memberOf)}
|
|
9710
|
+
${renderSectionAt("pinned")}
|
|
9711
|
+
${ordered.length === 0 ? "" : collectionsArea(context, view.sectionsCollapsed)}
|
|
9712
|
+
${INDEX_SECTIONS.filter((section) => section !== "pinned").map(renderSectionAt).join("\n ")}
|
|
9590
9713
|
${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>`}
|
|
9591
9714
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
9592
|
-
|
|
9715
|
+
${ordered.length === 0 ? "" : renderCollectionsDialog()}
|
|
9716
|
+
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned, Done and queued reviews, and reviews in a collection, 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>
|
|
9593
9717
|
</main>
|
|
9594
9718
|
</div>
|
|
9595
9719
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
@@ -9605,7 +9729,7 @@ ${renderWorkspaceRail({
|
|
|
9605
9729
|
* per risk level would be four more controls for a question ("show me only the risky ones") that
|
|
9606
9730
|
* sorting by recency already answers badly and that scanning answers well.
|
|
9607
9731
|
*/
|
|
9608
|
-
function renderControls$1(queued, authors, anyRequest) {
|
|
9732
|
+
function renderControls$1(queued, authors, anyRequest, anyCollection) {
|
|
9609
9733
|
return `<div class="prb-index-controls">
|
|
9610
9734
|
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
9611
9735
|
${renderAuthorFilter(authors)}
|
|
@@ -9618,10 +9742,29 @@ function renderControls$1(queued, authors, anyRequest) {
|
|
|
9618
9742
|
</select>
|
|
9619
9743
|
</label>
|
|
9620
9744
|
${anyRequest ? renderPrFilter() : ""}
|
|
9745
|
+
${renderCollectionsFilter(anyCollection)}
|
|
9621
9746
|
${renderClearFilters()}
|
|
9622
9747
|
</div>`;
|
|
9623
9748
|
}
|
|
9624
9749
|
/**
|
|
9750
|
+
* Which collections to show, any of several, and Uncollected for the reviews in none.
|
|
9751
|
+
*
|
|
9752
|
+
* A menu holding checkboxes rather than a select, because a select chooses one thing and this
|
|
9753
|
+
* chooses several, and because the list can be long enough to want its own search. The options are
|
|
9754
|
+
* filled in by the browser from the page's list of collections, so one made or renamed on the page
|
|
9755
|
+
* appears here without a reload. Hidden while there are no collections: a filter whose only choice
|
|
9756
|
+
* is Uncollected is not a choice.
|
|
9757
|
+
*/
|
|
9758
|
+
function renderCollectionsFilter(anyCollection) {
|
|
9759
|
+
return `<div class="prb-index-status prb-collections-filter" data-menu data-menu-form data-collections-filter${anyCollection ? "" : " hidden"}>
|
|
9760
|
+
<button type="button" class="prb-collections-filter-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Filter by collection">Collections: <span data-collections-filter-label>All</span></button>
|
|
9761
|
+
<div class="prb-menu-panel prb-collections-filter-panel" data-menu-panel hidden>
|
|
9762
|
+
<input type="search" class="prb-collections-filter-search" data-collections-filter-search placeholder="Find a collection" aria-label="Find a collection" spellcheck="false">
|
|
9763
|
+
<div class="prb-collections-filter-options" data-collections-filter-options role="group" aria-label="Collections"></div>
|
|
9764
|
+
</div>
|
|
9765
|
+
</div>`;
|
|
9766
|
+
}
|
|
9767
|
+
/**
|
|
9625
9768
|
* Puts the search and every filter back at once. Hidden until the browser finds one narrowing the
|
|
9626
9769
|
* list: the hash is the browser's to read, and without script there is nothing to clear.
|
|
9627
9770
|
*/
|
|
@@ -9671,6 +9814,141 @@ function renderAuthorFilter(authors) {
|
|
|
9671
9814
|
</label>`;
|
|
9672
9815
|
}
|
|
9673
9816
|
/**
|
|
9817
|
+
* The Collections area, between Pinned and Active: one disclosure per collection, each listing its
|
|
9818
|
+
* reviews that still need attention, with its Done reviews folded beneath.
|
|
9819
|
+
*
|
|
9820
|
+
* Exported for the routes that change collections, which answer with this markup so the browser can
|
|
9821
|
+
* swap the area in place: every card instance is drawn by `renderCard`, here and on a full load, and
|
|
9822
|
+
* never copied in the browser.
|
|
9823
|
+
*/
|
|
9824
|
+
/** Collection ids by lineage key, in collection order: what a card's `data-collections` says. */
|
|
9825
|
+
function membershipsByLineage(collections) {
|
|
9826
|
+
const memberOf = /* @__PURE__ */ new Map();
|
|
9827
|
+
for (const collection of collections) for (const key of collection.members) memberOf.set(key, [...memberOf.get(key) ?? [], collection.id]);
|
|
9828
|
+
return memberOf;
|
|
9829
|
+
}
|
|
9830
|
+
function renderCollectionsArea(view) {
|
|
9831
|
+
return collectionsArea(indexContext(view), view.sectionsCollapsed);
|
|
9832
|
+
}
|
|
9833
|
+
function collectionsArea(context, collapsed) {
|
|
9834
|
+
const { ordered, refs, collections, memberOf, activityOf, inProgressOnly, renderCards } = context;
|
|
9835
|
+
const registry = JSON.stringify(refs).replace(/</g, "\\u003c");
|
|
9836
|
+
const blocks = collections.map((collection) => {
|
|
9837
|
+
const members = ordered.filter((group) => (memberOf.get(group.key) ?? []).includes(collection.id));
|
|
9838
|
+
const place = (group) => collectionPlacement(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all");
|
|
9839
|
+
const drawn = members.filter((group) => !inProgressOnly.has(group.latest.id));
|
|
9840
|
+
const active = members.filter((group) => place(group) !== "completed");
|
|
9841
|
+
const completed = members.filter((group) => place(group) === "completed");
|
|
9842
|
+
return renderCollection(collection, {
|
|
9843
|
+
active: drawn.filter((group) => place(group) === "active").length,
|
|
9844
|
+
completed: drawn.filter((group) => place(group) === "completed").length,
|
|
9845
|
+
pinned: drawn.filter((group) => group.latest.pinned === true).length,
|
|
9846
|
+
members: members.length
|
|
9847
|
+
}, collection.members.length - members.length, collapsed, {
|
|
9848
|
+
active: renderCards(active, { collection: collection.id }, (group) => place(group) === void 0),
|
|
9849
|
+
completed: renderCards(completed, { collection: collection.id })
|
|
9850
|
+
});
|
|
9851
|
+
});
|
|
9852
|
+
return `<section class="prb-collections" data-collections-area aria-label="Collections">
|
|
9853
|
+
<script type="application/json" data-collections-registry>${registry}<\/script>
|
|
9854
|
+
<div class="prb-collections-head">
|
|
9855
|
+
<h2 class="prb-collections-title">Collections</h2>
|
|
9856
|
+
${collections.length === 0 ? "<p class=\"prb-collections-hint\">Group reviews that belong together, such as one refactor or one release.</p>" : ""}
|
|
9857
|
+
</div>
|
|
9858
|
+
${blocks.join("\n ")}
|
|
9859
|
+
${renderNewCollection()}
|
|
9860
|
+
</section>`;
|
|
9861
|
+
}
|
|
9862
|
+
function renderCollection(collection, counts, omitted, collapsed, cards) {
|
|
9863
|
+
const id = escapeHtml$1(collection.id);
|
|
9864
|
+
const empty = collectionEmptyText(counts, false);
|
|
9865
|
+
return `<div class="prb-collection" data-collection="${id}">
|
|
9866
|
+
<details class="prb-index-section" data-section="${escapeHtml$1(collectionFold(collection.id))}"${sectionOpen(collectionFold(collection.id), collapsed) ? " open" : ""}>
|
|
9867
|
+
<summary class="prb-index-section-head"><span data-collection-name>${escapeHtml$1(collection.name)}</span> · <span data-section-count>${escapeHtml$1(collectionCountLabel(counts.active, counts.pinned))}</span></summary>
|
|
9868
|
+
<p class="prb-collection-empty" data-collection-empty${empty === void 0 ? " hidden" : ""}>${escapeHtml$1(empty ?? "")}</p>
|
|
9869
|
+
<div class="prb-cards" data-cards="${escapeHtml$1(collectionFold(collection.id))}">
|
|
9870
|
+
${cards.active}
|
|
9871
|
+
</div>
|
|
9872
|
+
${renderLoadMore$1(collectionFold(collection.id))}
|
|
9873
|
+
${omitted > 0 ? `<p class="prb-collection-empty" data-collection-omitted>${escapeHtml$1(omittedText(omitted))}</p>` : ""}
|
|
9874
|
+
<details class="prb-collection-completed" data-section="${escapeHtml$1(completedFold(collection.id))}"${sectionOpen(completedFold(collection.id), collapsed) ? " open" : ""}${counts.completed === 0 ? " hidden" : ""}>
|
|
9875
|
+
<summary class="prb-collection-completed-head" data-completed-label>${escapeHtml$1(completedLabel(counts.completed))}</summary>
|
|
9876
|
+
<div class="prb-cards" data-cards="${escapeHtml$1(completedFold(collection.id))}">
|
|
9877
|
+
${cards.completed}
|
|
9878
|
+
</div>
|
|
9879
|
+
${renderLoadMore$1(completedFold(collection.id))}
|
|
9880
|
+
</details>
|
|
9881
|
+
</details>
|
|
9882
|
+
${renderCollectionMenu(collection)}
|
|
9883
|
+
</div>`;
|
|
9884
|
+
}
|
|
9885
|
+
/**
|
|
9886
|
+
* A collection's own menu: rename it in place, or delete it.
|
|
9887
|
+
*
|
|
9888
|
+
* Deleting says what it does not do, which is the thing a reader worries about: the reviews stay.
|
|
9889
|
+
* It says what does change, too, because collected reviews are kept for longer and these go back to
|
|
9890
|
+
* the ordinary thirty days unless something else keeps them.
|
|
9891
|
+
*/
|
|
9892
|
+
function renderCollectionMenu(collection) {
|
|
9893
|
+
const id = escapeHtml$1(collection.id);
|
|
9894
|
+
return `<div class="prb-menu prb-collection-menu" data-menu data-menu-form>
|
|
9895
|
+
<button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(`Actions for collection ${collection.name}`)}">⋯</button>
|
|
9896
|
+
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
9897
|
+
<div data-menu-items>
|
|
9898
|
+
<button type="button" class="prb-menu-item" data-collection-rename="${id}">Rename collection</button>
|
|
9899
|
+
<button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-delete="${id}">Delete collection</button>
|
|
9900
|
+
</div>
|
|
9901
|
+
<form class="prb-collection-rename" data-collection-rename-form="${id}" hidden>
|
|
9902
|
+
<label class="prb-menu-question" for="prb-rename-${id}">New name</label>
|
|
9903
|
+
<input type="text" id="prb-rename-${id}" class="prb-collection-name" data-collection-name-input value="${escapeHtml$1(collection.name)}" maxlength="60" autocomplete="off" spellcheck="false">
|
|
9904
|
+
<p class="prb-collection-error" data-collection-error hidden role="alert"></p>
|
|
9905
|
+
<div class="prb-menu-buttons">
|
|
9906
|
+
<button type="submit" class="prb-menu-item">Save</button>
|
|
9907
|
+
<button type="button" class="prb-menu-item" data-collection-cancel>Cancel</button>
|
|
9908
|
+
</div>
|
|
9909
|
+
</form>
|
|
9910
|
+
<div class="prb-menu-confirm" data-menu-confirm hidden>
|
|
9911
|
+
<p class="prb-menu-question">${escapeHtml$1(`Delete the collection ${collection.name}?`)}</p>
|
|
9912
|
+
<p class="prb-menu-note">Its reviews stay where they are. They go back to being kept for thirty days unless they are pinned, Done or in your review queue.</p>
|
|
9913
|
+
<div class="prb-menu-buttons">
|
|
9914
|
+
<button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-confirm-delete="${id}">Delete</button>
|
|
9915
|
+
<button type="button" class="prb-menu-item" data-cancel-delete>Cancel</button>
|
|
9916
|
+
</div>
|
|
9917
|
+
</div>
|
|
9918
|
+
</div>
|
|
9919
|
+
</div>`;
|
|
9920
|
+
}
|
|
9921
|
+
/** New collection, which opens a name field in place. */
|
|
9922
|
+
function renderNewCollection() {
|
|
9923
|
+
return `<div class="prb-collection-new">
|
|
9924
|
+
<button type="button" class="prb-index-more prb-collection-new-button" data-collection-new><span aria-hidden="true">+</span> New collection</button>
|
|
9925
|
+
<form class="prb-collection-new-form" data-collection-new-form hidden>
|
|
9926
|
+
<input type="text" class="prb-collection-name" data-collection-name-input placeholder="Collection name" aria-label="New collection name" maxlength="60" autocomplete="off" spellcheck="false">
|
|
9927
|
+
<button type="submit" class="prb-bulk-button">Create</button>
|
|
9928
|
+
<button type="button" class="prb-bulk-button" data-collection-cancel>Cancel</button>
|
|
9929
|
+
<p class="prb-collection-error" data-collection-error hidden role="alert"></p>
|
|
9930
|
+
</form>
|
|
9931
|
+
</div>`;
|
|
9932
|
+
}
|
|
9933
|
+
/**
|
|
9934
|
+
* Manage collections, for one card or for a selection. Built once; the browser fills the list from
|
|
9935
|
+
* the page's collections and the reviews it was opened for, because the boxes' states depend on
|
|
9936
|
+
* which reviews those are.
|
|
9937
|
+
*/
|
|
9938
|
+
function renderCollectionsDialog() {
|
|
9939
|
+
return `<dialog class="prb-dialog prb-collections-dialog" data-collections-dialog aria-labelledby="prb-collections-dialog-title">
|
|
9940
|
+
<h2 id="prb-collections-dialog-title">Collections</h2>
|
|
9941
|
+
<p data-collections-dialog-for></p>
|
|
9942
|
+
<input type="search" class="prb-collections-dialog-search" data-collections-dialog-search placeholder="Find or create a collection" aria-label="Find or create a collection" spellcheck="false" autocomplete="off">
|
|
9943
|
+
<div class="prb-collections-dialog-list" data-collections-dialog-list role="group" aria-label="Collections"></div>
|
|
9944
|
+
<button type="button" class="prb-collections-dialog-create" data-collections-dialog-create hidden></button>
|
|
9945
|
+
<p class="prb-collection-error" data-collections-dialog-error hidden role="alert"></p>
|
|
9946
|
+
<div class="prb-dialog-actions">
|
|
9947
|
+
<button type="button" data-collections-dialog-close>Done</button>
|
|
9948
|
+
</div>
|
|
9949
|
+
</dialog>`;
|
|
9950
|
+
}
|
|
9951
|
+
/**
|
|
9674
9952
|
* One section's Load more, which the browser shows once that section holds more than it is showing.
|
|
9675
9953
|
*
|
|
9676
9954
|
* One per section rather than one pager for the page, so revealing older Done reviews never costs
|
|
@@ -9708,10 +9986,11 @@ function renderSection(section, groups, renderCards, open, shown = groups.length
|
|
|
9708
9986
|
* index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
|
|
9709
9987
|
* counts once here and once in its own section, which is the point.
|
|
9710
9988
|
*/
|
|
9711
|
-
function renderInProgress$1(operations, groups, positions, prStates, now, open) {
|
|
9989
|
+
function renderInProgress$1(operations, groups, positions, prStates, now, open, memberOf = /* @__PURE__ */ new Map()) {
|
|
9712
9990
|
if (operations.length === 0) return "";
|
|
9713
9991
|
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
9714
|
-
const
|
|
9992
|
+
const collections = operationCollections(operations, groups, memberOf);
|
|
9993
|
+
const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now, collections[operation.key] ?? [])).join("");
|
|
9715
9994
|
return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
|
|
9716
9995
|
<summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
|
|
9717
9996
|
<div class="prb-cards" data-operations>
|
|
@@ -9725,6 +10004,23 @@ function renderInProgress$1(operations, groups, positions, prStates, now, open)
|
|
|
9725
10004
|
* one, which is what that review's title, author and state still are: the filters then treat a
|
|
9726
10005
|
* re-review of a known pull request as that pull request, rather than as somebody unknown.
|
|
9727
10006
|
*/
|
|
10007
|
+
/**
|
|
10008
|
+
* The collections each piece of work's review is in, by the work's key: the lineage of whichever
|
|
10009
|
+
* review `reviewOf` finds, so a waiting row with no review yet still answers the Collections filter
|
|
10010
|
+
* the way its card will. Also what the collections routes send back, so a change of membership
|
|
10011
|
+
* reaches the In progress section without a reload.
|
|
10012
|
+
*/
|
|
10013
|
+
function operationCollections(operations, groups, memberOf) {
|
|
10014
|
+
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
10015
|
+
const lineage = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, group.key])));
|
|
10016
|
+
const answer = {};
|
|
10017
|
+
for (const operation of operations) {
|
|
10018
|
+
const review = reviewOf(operation, reviews);
|
|
10019
|
+
const key = review ? lineage.get(review.id) : void 0;
|
|
10020
|
+
answer[operation.key] = key === void 0 ? [] : [...memberOf.get(key) ?? []];
|
|
10021
|
+
}
|
|
10022
|
+
return answer;
|
|
10023
|
+
}
|
|
9728
10024
|
function reviewOf(operation, reviews) {
|
|
9729
10025
|
if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
|
|
9730
10026
|
const row = operation.row;
|
|
@@ -9746,7 +10042,7 @@ function reviewOf(operation, reviews) {
|
|
|
9746
10042
|
* A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
|
|
9747
10043
|
* for a slot has no review to open, so it is not a link.
|
|
9748
10044
|
*/
|
|
9749
|
-
function renderOperation(operation, review, positions, prStates, now) {
|
|
10045
|
+
function renderOperation(operation, review, positions, prStates, now, collections = []) {
|
|
9750
10046
|
const row = operation.row;
|
|
9751
10047
|
const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
|
|
9752
10048
|
const repoPath = review?.repoPath ?? row?.repoPath;
|
|
@@ -9777,7 +10073,7 @@ function renderOperation(operation, review, positions, prStates, now) {
|
|
|
9777
10073
|
${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
|
|
9778
10074
|
</p>`;
|
|
9779
10075
|
const href = own ? `/r/${encodeURIComponent(own.id)}?t=${encodeURIComponent(own.token)}` : void 0;
|
|
9780
|
-
return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}">
|
|
10076
|
+
return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}" data-collections="${escapeHtml$1(collections.join(" "))}">
|
|
9781
10077
|
${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
9782
10078
|
</article>`;
|
|
9783
10079
|
}
|
|
@@ -9802,6 +10098,7 @@ function renderBulkBar() {
|
|
|
9802
10098
|
<button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
|
|
9803
10099
|
<button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
|
|
9804
10100
|
<button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
|
|
10101
|
+
<button type="button" class="prb-bulk-button" data-bulk-collections disabled>Manage collections</button>
|
|
9805
10102
|
<button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
|
|
9806
10103
|
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
|
|
9807
10104
|
${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
|
|
@@ -9838,13 +10135,21 @@ function renderBulkBar() {
|
|
|
9838
10135
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
9839
10136
|
* than a rebuild of the list.
|
|
9840
10137
|
*/
|
|
9841
|
-
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false
|
|
10138
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false, instance = {
|
|
10139
|
+
placement: "home",
|
|
10140
|
+
memberOf: [],
|
|
10141
|
+
refs: [],
|
|
10142
|
+
hidden: false
|
|
10143
|
+
}) {
|
|
9842
10144
|
const review = group.latest;
|
|
10145
|
+
const placement = instance.placement === "home" ? "home" : `collection:${instance.placement.collection}`;
|
|
10146
|
+
const line = collectionsLine(instance.memberOf, instance.refs, instance.placement === "home" ? void 0 : instance.placement.collection);
|
|
10147
|
+
const hidden = inProgressOnly || instance.hidden;
|
|
9843
10148
|
const status = statusOf(review);
|
|
9844
10149
|
const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
|
|
9845
10150
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
9846
10151
|
const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
|
|
9847
|
-
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}"` : ""}${activityAttributes(activity)} 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))}"${inProgressOnly ? " data-in-progress-only hidden" : ""}>
|
|
10152
|
+
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}"` : ""}${activityAttributes(activity)} 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))}" data-placement="${escapeHtml$1(placement)}" data-collections="${escapeHtml$1(instance.memberOf.join(" "))}"${inProgressOnly ? " data-in-progress-only" : ""}${hidden ? " hidden" : ""}>
|
|
9848
10153
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
9849
10154
|
<button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "★" : "☆"}</button>
|
|
9850
10155
|
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
@@ -9870,6 +10175,7 @@ function renderCard(group, now, activity, rank, analysis, queued, moved, prState
|
|
|
9870
10175
|
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
9871
10176
|
${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
|
|
9872
10177
|
${renderAnalysisState(analysis)}
|
|
10178
|
+
<span class="prb-card-collections" data-collections-line${line === void 0 ? " hidden" : ""}>${escapeHtml$1(line ?? "")}</span>
|
|
9873
10179
|
<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>
|
|
9874
10180
|
</p>
|
|
9875
10181
|
</a>
|
|
@@ -9910,7 +10216,8 @@ function renderCardMenu(group, followed, queued, status, activity) {
|
|
|
9910
10216
|
queue: queued ? "remove" : "add",
|
|
9911
10217
|
mark,
|
|
9912
10218
|
lifecycle,
|
|
9913
|
-
fetch: true
|
|
10219
|
+
fetch: true,
|
|
10220
|
+
collections: true
|
|
9914
10221
|
});
|
|
9915
10222
|
const all = `all ${count} reviews following ${followed}`;
|
|
9916
10223
|
return renderMenu({
|
|
@@ -9923,6 +10230,7 @@ function renderCardMenu(group, followed, queued, status, activity) {
|
|
|
9923
10230
|
mark,
|
|
9924
10231
|
lifecycle,
|
|
9925
10232
|
fetch: true,
|
|
10233
|
+
collections: true,
|
|
9926
10234
|
copy: "Copy latest review ID"
|
|
9927
10235
|
});
|
|
9928
10236
|
}
|
|
@@ -9953,6 +10261,7 @@ function renderMenu(item) {
|
|
|
9953
10261
|
${markItem}
|
|
9954
10262
|
${lifecycleItems}
|
|
9955
10263
|
${queueItem}
|
|
10264
|
+
${item.collections ? `<button type="button" class="prb-menu-item" data-collections-for="${id}">Manage collections…</button>` : ""}
|
|
9956
10265
|
<button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
|
|
9957
10266
|
<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
|
|
9958
10267
|
</div>
|
|
@@ -10363,14 +10672,15 @@ function writeChangeMapRegister(register) {
|
|
|
10363
10672
|
}, null, 2)}\n`);
|
|
10364
10673
|
} catch {}
|
|
10365
10674
|
}
|
|
10366
|
-
/**
|
|
10675
|
+
/**
|
|
10676
|
+
* Known sections and collection folds with a boolean only. A hand-edited key we do not draw is
|
|
10677
|
+
* dropped on the next write. A fold for a collection since deleted elsewhere is kept until
|
|
10678
|
+
* `forgetSections` is told, which costs one line of JSON and nothing on the page.
|
|
10679
|
+
*/
|
|
10367
10680
|
function readReviewSections(value) {
|
|
10368
10681
|
if (!value || typeof value !== "object") return {};
|
|
10369
10682
|
const sections = {};
|
|
10370
|
-
for (const section of
|
|
10371
|
-
const collapsed = value[section];
|
|
10372
|
-
if (typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10373
|
-
}
|
|
10683
|
+
for (const [section, collapsed] of Object.entries(value)) if (isFoldKey(section) && typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10374
10684
|
return Object.keys(sections).length === 0 ? {} : { reviewSections: sections };
|
|
10375
10685
|
}
|
|
10376
10686
|
/**
|
|
@@ -10392,6 +10702,19 @@ function writeSectionCollapsed(section, collapsed) {
|
|
|
10392
10702
|
}, null, 2)}\n`);
|
|
10393
10703
|
} catch {}
|
|
10394
10704
|
}
|
|
10705
|
+
/** Drop folds nothing will draw again: a deleted collection's. Writes nothing when none was stored. */
|
|
10706
|
+
function forgetSections(sections) {
|
|
10707
|
+
try {
|
|
10708
|
+
const current = readPreferences();
|
|
10709
|
+
const stored = current.reviewSections;
|
|
10710
|
+
if (!stored || !sections.some((section) => section in stored)) return;
|
|
10711
|
+
const reviewSections = Object.fromEntries(Object.entries(stored).filter(([section]) => !sections.includes(section)));
|
|
10712
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
10713
|
+
...current,
|
|
10714
|
+
reviewSections
|
|
10715
|
+
}, null, 2)}\n`);
|
|
10716
|
+
} catch {}
|
|
10717
|
+
}
|
|
10395
10718
|
/** As `readReviewSections`, for the Runs page. */
|
|
10396
10719
|
function readRunsSections(value) {
|
|
10397
10720
|
if (!value || typeof value !== "object") return {};
|
|
@@ -11164,6 +11487,25 @@ feature: E</pre>
|
|
|
11164
11487
|
<p>Deleting a review removes its record and the isolated checkouts it was made in. It never
|
|
11165
11488
|
touches your branch or its commits. Delete one from its menu, or tick reviews in the Reviews
|
|
11166
11489
|
list, one at a time or a whole branch at once, and delete them together.</p>`
|
|
11490
|
+
},
|
|
11491
|
+
{
|
|
11492
|
+
id: "collections",
|
|
11493
|
+
title: "Collections",
|
|
11494
|
+
body: `<p>A collection groups reviews that belong together, such as the pull requests of one
|
|
11495
|
+
refactor or one release. It says what belongs together; whether each review still needs your
|
|
11496
|
+
attention is still Active, Waiting or Done, so adding or removing a review never changes its
|
|
11497
|
+
state.</p>
|
|
11498
|
+
<p>Make one with New collection on the Reviews page, or from Manage collections in a card's menu.
|
|
11499
|
+
To add several reviews at once, choose Select, tick them, and choose Manage collections. A review
|
|
11500
|
+
can be in more than one collection. Ticking a collection adds every selected review to it and
|
|
11501
|
+
leaves their other collections alone.</p>
|
|
11502
|
+
<p>Each collection lists its reviews that are not Done. Marking one Done moves it under Show
|
|
11503
|
+
completed, in every collection it is in, and Move to active brings it back. A review in a
|
|
11504
|
+
collection is listed there rather than under Active, Waiting or Done. A pinned review stays under
|
|
11505
|
+
Pinned. The Collections filter shows chosen collections, or Uncollected for reviews in none.</p>
|
|
11506
|
+
<p>Reviews in a collection are kept for ninety days, like pinned and Done reviews, counted from
|
|
11507
|
+
when you last opened or marked them. Adding one to a collection doesn't restart that count.
|
|
11508
|
+
Deleting a collection keeps its reviews.</p>`
|
|
11167
11509
|
},
|
|
11168
11510
|
{
|
|
11169
11511
|
id: "several-pull-requests",
|
|
@@ -11217,8 +11559,8 @@ feature: E</pre>
|
|
|
11217
11559
|
automatically:</p>
|
|
11218
11560
|
<ul>
|
|
11219
11561
|
<li><b>Reviews</b> are kept for thirty days after you last opened or changed them.</li>
|
|
11220
|
-
<li><b>Reviews you kept</b> (saved, marked Done, or on your review queue) are
|
|
11221
|
-
days instead.</li>
|
|
11562
|
+
<li><b>Reviews you kept</b> (saved, marked Done, in a collection, or on your review queue) are
|
|
11563
|
+
kept for ninety days instead.</li>
|
|
11222
11564
|
<li><b>Isolated checkouts</b> are removed six hours after their work finishes. See
|
|
11223
11565
|
<a href="#isolated-checkouts">Isolated checkouts</a>.</li>
|
|
11224
11566
|
<li><b>Runs</b>, the record on the Runs page of what ran and how it ended, are kept for seven
|
|
@@ -11395,6 +11737,50 @@ function renderNotificationRow(notification, gone) {
|
|
|
11395
11737
|
</li>`;
|
|
11396
11738
|
}
|
|
11397
11739
|
//#endregion
|
|
11740
|
+
//#region ../../packages/review-harness/src/workspace/collections_live.ts
|
|
11741
|
+
/**
|
|
11742
|
+
* Collections as they relate to reviews that actually exist.
|
|
11743
|
+
*
|
|
11744
|
+
* `collections.ts` stores lineage keys and must not learn to read the review store, for the cycle
|
|
11745
|
+
* `queue_live.ts` describes: the prune in `store.ts` imports it. Anything that asks both questions
|
|
11746
|
+
* lives here, which imports both and is imported by neither.
|
|
11747
|
+
*/
|
|
11748
|
+
/**
|
|
11749
|
+
* Drop members whose lineage has no review any more, deleted elsewhere or pruned by age, and store
|
|
11750
|
+
* the result if that changed anything. Read against every group, not the windowed list, so a branch
|
|
11751
|
+
* the recency window happens to hide keeps its place.
|
|
11752
|
+
*
|
|
11753
|
+
* Compacted again under the lock rather than written from what was read, so a member somebody added
|
|
11754
|
+
* since is kept. On a failed write the compacted copy still stands in, so the page draws what exists.
|
|
11755
|
+
*/
|
|
11756
|
+
function settleCollections(groups) {
|
|
11757
|
+
const live = new Set(groups.map((group) => group.key));
|
|
11758
|
+
const compact = (collections) => collections.some((collection) => collection.members.some((key) => !live.has(key))) ? collections.map((collection) => ({
|
|
11759
|
+
...collection,
|
|
11760
|
+
members: collection.members.filter((key) => live.has(key))
|
|
11761
|
+
})) : null;
|
|
11762
|
+
const read = readCollections();
|
|
11763
|
+
const compacted = compact(read);
|
|
11764
|
+
if (compacted === null) return read;
|
|
11765
|
+
return updateCollections((current) => compact(current)) ?? compacted;
|
|
11766
|
+
}
|
|
11767
|
+
/**
|
|
11768
|
+
* The lineage each review id belongs to, for the routes: the page sends the ids on its cards, and
|
|
11769
|
+
* the lineage is the server's to derive. An id with no review, or one that cannot be summarised, is
|
|
11770
|
+
* left out, and the caller says so.
|
|
11771
|
+
*/
|
|
11772
|
+
function lineageKeysFor(ids) {
|
|
11773
|
+
const keys = /* @__PURE__ */ new Map();
|
|
11774
|
+
for (const id of new Set(ids)) {
|
|
11775
|
+
const workspace = loadWorkspace(id);
|
|
11776
|
+
if (!workspace) continue;
|
|
11777
|
+
try {
|
|
11778
|
+
keys.set(id, lineageKey(summarise(workspace)));
|
|
11779
|
+
} catch {}
|
|
11780
|
+
}
|
|
11781
|
+
return keys;
|
|
11782
|
+
}
|
|
11783
|
+
//#endregion
|
|
11398
11784
|
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11399
11785
|
/**
|
|
11400
11786
|
* The local Git working folders PR Review Buddy can find right now.
|
|
@@ -12610,6 +12996,7 @@ function reviewsRevision(live = analysisStates(), operations = liveOperations())
|
|
|
12610
12996
|
}
|
|
12611
12997
|
for (const operation of [...operations].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) hash.update(`op:${operation.key}:${operation.kind}:${operation.phase ?? ""}:${operation.reviewId ?? ""}\n`);
|
|
12612
12998
|
hash.update(`queue:${queueStamp()}\n`);
|
|
12999
|
+
hash.update(`collections:${collectionsStamp()}\n`);
|
|
12613
13000
|
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
12614
13001
|
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
12615
13002
|
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`);
|
|
@@ -14606,6 +14993,8 @@ async function handle(req, res, options) {
|
|
|
14606
14993
|
if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
|
|
14607
14994
|
if (url.pathname === "/sections") return handleSections(req, res, options.indexToken);
|
|
14608
14995
|
if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
|
|
14996
|
+
if (url.pathname === "/collections") return handleCollections(req, res, options.indexToken);
|
|
14997
|
+
if (url.pathname === "/collections/members") return handleCollectionMembers(req, res, options.indexToken);
|
|
14609
14998
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
14610
14999
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
14611
15000
|
if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
|
|
@@ -14705,18 +15094,44 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
14705
15094
|
}
|
|
14706
15095
|
const cookie = indexCookieHeader(indexToken);
|
|
14707
15096
|
if (onward) return redirect(res, onward, cookie);
|
|
15097
|
+
sendHtml(res, 200, renderReviewsPage(indexView()), cookie);
|
|
15098
|
+
}
|
|
15099
|
+
/**
|
|
15100
|
+
* Which cards a page is drawn with: each branch, by its newest review. Equality only. Changes when a
|
|
15101
|
+
* branch is analysed again, or a card is added or deleted, which are the changes that make a
|
|
15102
|
+
* fragment drawn now wrong for a page drawn then. Nothing else moves it.
|
|
15103
|
+
*/
|
|
15104
|
+
function cardsSnapshot(groups) {
|
|
15105
|
+
const hash = createHash("sha1");
|
|
15106
|
+
for (const line of groups.map((group) => `${group.key}\t${group.latest.id}`).sort()) hash.update(`${line}\n`);
|
|
15107
|
+
return hash.digest("hex").slice(0, 16);
|
|
15108
|
+
}
|
|
15109
|
+
/**
|
|
15110
|
+
* Everything the reviews index is drawn from, read in one pass.
|
|
15111
|
+
*
|
|
15112
|
+
* Shared by the page and by the collections routes, which answer with the Collections area drawn
|
|
15113
|
+
* from the same view, so a card in a collection after a change is the card a reload would draw.
|
|
15114
|
+
*/
|
|
15115
|
+
function indexView() {
|
|
14708
15116
|
const states = analysisStates();
|
|
14709
15117
|
const operations = liveOperations();
|
|
14710
15118
|
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
14711
15119
|
const everyGroup = groupByLineage(reviews);
|
|
14712
15120
|
const queue = settleQueue(everyGroup);
|
|
15121
|
+
const collections = settleCollections(everyGroup);
|
|
14713
15122
|
const kept = indexKeptIds(reviews, queue);
|
|
14714
15123
|
const activity = reviewActivity(everyGroup);
|
|
14715
|
-
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup
|
|
15124
|
+
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup, {
|
|
15125
|
+
keys: new Set(collections.flatMap((collection) => collection.members)),
|
|
15126
|
+
limit: MAX_COLLECTED_BRANCHES
|
|
15127
|
+
});
|
|
14716
15128
|
const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
|
|
14717
|
-
|
|
15129
|
+
const live = queue.filter((id) => rendered.has(id));
|
|
15130
|
+
return {
|
|
14718
15131
|
groups,
|
|
14719
|
-
|
|
15132
|
+
snapshot: cardsSnapshot(groups),
|
|
15133
|
+
queue: live,
|
|
15134
|
+
collections,
|
|
14720
15135
|
analyses: states,
|
|
14721
15136
|
operations,
|
|
14722
15137
|
moved: movedReviews(),
|
|
@@ -14727,7 +15142,7 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
14727
15142
|
driving: isDriving(),
|
|
14728
15143
|
theme: readPreferences().theme,
|
|
14729
15144
|
sectionsCollapsed: readPreferences().reviewSections
|
|
14730
|
-
}
|
|
15145
|
+
};
|
|
14731
15146
|
}
|
|
14732
15147
|
/**
|
|
14733
15148
|
* Where `?go=` is allowed to send someone, which is one shape and no other.
|
|
@@ -15381,7 +15796,7 @@ async function handleSections(req, res, indexToken) {
|
|
|
15381
15796
|
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15382
15797
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15383
15798
|
const body = await readJson(req);
|
|
15384
|
-
if (!
|
|
15799
|
+
if (!isFoldKey(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
|
|
15385
15800
|
writeSectionCollapsed(body.section, body.collapsed);
|
|
15386
15801
|
send(res, 200, { ok: true });
|
|
15387
15802
|
}
|
|
@@ -15450,6 +15865,102 @@ function addMany(res, ids) {
|
|
|
15450
15865
|
});
|
|
15451
15866
|
}
|
|
15452
15867
|
/**
|
|
15868
|
+
* Making, renaming and deleting collections, from the Reviews page.
|
|
15869
|
+
*
|
|
15870
|
+
* The index cookie, like the queue: this is the list acting on itself. Every answer that changed
|
|
15871
|
+
* something carries the collections as stored and the Collections area redrawn from them, so the
|
|
15872
|
+
* page swaps in exactly what a reload would show rather than building cards of its own.
|
|
15873
|
+
*/
|
|
15874
|
+
async function handleCollections(req, res, indexToken) {
|
|
15875
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15876
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15877
|
+
const body = await readJson(req);
|
|
15878
|
+
const action = body?.action;
|
|
15879
|
+
const name = typeof body?.name === "string" ? body.name : "";
|
|
15880
|
+
const id = isCollectionId(body?.id) ? body.id : null;
|
|
15881
|
+
const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
|
|
15882
|
+
if (action === "create") return answerCollections(res, createCollection(name), [], snapshot);
|
|
15883
|
+
if (action === "rename" && id) return answerCollections(res, renameCollection(id, name), [], snapshot);
|
|
15884
|
+
if (action === "delete" && id) {
|
|
15885
|
+
const outcome = deleteCollection(id);
|
|
15886
|
+
if (outcome.ok) forgetSections([collectionFold(id), completedFold(id)]);
|
|
15887
|
+
return answerCollections(res, outcome, [], snapshot);
|
|
15888
|
+
}
|
|
15889
|
+
send(res, 400, { error: "Unknown collections action." });
|
|
15890
|
+
}
|
|
15891
|
+
/**
|
|
15892
|
+
* Putting reviews into one collection, or taking them out of it: one card's menu or the bulk bar.
|
|
15893
|
+
*
|
|
15894
|
+
* The page sends review ids, the ids on its cards, and the lineage each belongs to is derived here,
|
|
15895
|
+
* because a lineage is what a collection holds and the server is the only side that can say which.
|
|
15896
|
+
* Only the named collection changes, whatever else the reviews belong to. `create` makes the
|
|
15897
|
+
* collection first, by name, so "Create Refactor" from the panel is one request and cannot leave an
|
|
15898
|
+
* empty collection behind a failed assignment without saying so.
|
|
15899
|
+
*/
|
|
15900
|
+
async function handleCollectionMembers(req, res, indexToken) {
|
|
15901
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15902
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15903
|
+
const body = await readJson(req);
|
|
15904
|
+
const action = body?.action;
|
|
15905
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((one) => typeof one === "string") : [];
|
|
15906
|
+
if (action !== "add" && action !== "remove" || ids.length === 0) return send(res, 400, { error: "Say which reviews, and whether to add or remove them." });
|
|
15907
|
+
const keys = lineageKeysFor(ids);
|
|
15908
|
+
const gone = ids.filter((one) => !keys.has(one));
|
|
15909
|
+
const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
|
|
15910
|
+
if (action === "add" && typeof body?.create === "string" && !isCollectionId(body?.collection)) {
|
|
15911
|
+
if (gone.length > 0) return send(res, 409, { error: "A review you selected has been deleted, so nothing was created. Reload the page and try again." });
|
|
15912
|
+
return answerCollections(res, createWithMembers(body.create, [...keys.values()]), [], snapshot);
|
|
15913
|
+
}
|
|
15914
|
+
const collection = isCollectionId(body?.collection) ? body.collection : null;
|
|
15915
|
+
if (collection === null) return send(res, 400, { error: "Say which collection." });
|
|
15916
|
+
answerCollections(res, setMembership([...keys.values()], collection, action), gone.map((one) => ({
|
|
15917
|
+
id: one,
|
|
15918
|
+
error: "A review you selected has been deleted."
|
|
15919
|
+
})), snapshot);
|
|
15920
|
+
}
|
|
15921
|
+
/**
|
|
15922
|
+
* The answer every collections route gives: what is stored, and the area drawn from it, or why not.
|
|
15923
|
+
*
|
|
15924
|
+
* **Only for the page it would fit.** The area's cards and the memberships are keyed by each
|
|
15925
|
+
* branch's newest review, and a page drawn before a branch was analysed again, or before one was
|
|
15926
|
+
* added or deleted, holds cards under other ids. Swapping a fragment drawn from today's reviews into
|
|
15927
|
+
* that page would mix the two. So the page says which cards it was drawn with (`snapshot`, from
|
|
15928
|
+
* `cardsSnapshot`), and when that is not what the server would draw now the answer is `stale`, with
|
|
15929
|
+
* no fragment: the change itself is stored either way, by lineage, and the page reloads to show it.
|
|
15930
|
+
*/
|
|
15931
|
+
function answerCollections(res, outcome, refused, snapshot) {
|
|
15932
|
+
if (!outcome.ok) {
|
|
15933
|
+
if (outcome.reason === "missing") return send(res, 404, { error: "That collection has been deleted." });
|
|
15934
|
+
if (outcome.reason === "full") return send(res, 409, { error: "That is more than a collection can hold." });
|
|
15935
|
+
if (outcome.reason === "unsaved") return send(res, 500, { error: "The collections could not be saved." });
|
|
15936
|
+
return send(res, 409, {
|
|
15937
|
+
error: nameProblemText(outcome.reason),
|
|
15938
|
+
reason: outcome.reason
|
|
15939
|
+
});
|
|
15940
|
+
}
|
|
15941
|
+
const view = indexView();
|
|
15942
|
+
const collections = (view.collections ?? []).map(({ id, name }) => ({
|
|
15943
|
+
id,
|
|
15944
|
+
name
|
|
15945
|
+
}));
|
|
15946
|
+
if (snapshot === "" || snapshot !== view.snapshot) return send(res, 200, {
|
|
15947
|
+
stale: true,
|
|
15948
|
+
collections,
|
|
15949
|
+
...outcome.id === void 0 ? {} : { id: outcome.id },
|
|
15950
|
+
refused
|
|
15951
|
+
});
|
|
15952
|
+
const memberOf = {};
|
|
15953
|
+
for (const group of view.groups) memberOf[group.latest.id] = (view.collections ?? []).filter((one) => one.members.includes(group.key)).map((one) => one.id);
|
|
15954
|
+
send(res, 200, {
|
|
15955
|
+
collections,
|
|
15956
|
+
...outcome.id === void 0 ? {} : { id: outcome.id },
|
|
15957
|
+
memberOf,
|
|
15958
|
+
operations: operationCollections(view.operations ?? [], view.groups, membershipsByLineage(view.collections ?? [])),
|
|
15959
|
+
area: renderCollectionsArea(view),
|
|
15960
|
+
refused
|
|
15961
|
+
});
|
|
15962
|
+
}
|
|
15963
|
+
/**
|
|
15453
15964
|
* Delete one review, or everything on one card.
|
|
15454
15965
|
*
|
|
15455
15966
|
* Authorised the same way pinning is, and for the same reason: this is the list acting on itself,
|
|
@@ -15606,6 +16117,13 @@ var MAX_LISTED_BRANCHES = 50;
|
|
|
15606
16117
|
*/
|
|
15607
16118
|
var MAX_KEPT_BRANCHES = 50;
|
|
15608
16119
|
/**
|
|
16120
|
+
* How many collected branches the list draws, in a window of their own beside the two above, so a
|
|
16121
|
+
* collection never competes with Pinned or the queue for a place. One collection's worth: a single
|
|
16122
|
+
* collection is always drawn whole, and only collections holding more than this between them leave
|
|
16123
|
+
* their oldest members out, which each collection then says.
|
|
16124
|
+
*/
|
|
16125
|
+
var MAX_COLLECTED_BRANCHES = 500;
|
|
16126
|
+
/**
|
|
15609
16127
|
* A finished review's workspace was served.
|
|
15610
16128
|
*
|
|
15611
16129
|
* **This counts page serves, not people and not sessions.** A reload, a restored tab, the CLI
|