prreviewbuddy 0.81.0 → 0.82.4
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 +56 -0
- package/dist/{delete_review-CFVFygqb.js → delete_review-BED4mPxB.js} +333 -23
- package/dist/main.js +1 -1
- package/dist/server.js +562 -37
- package/package.json +1 -1
- package/static/{chunk-DnOyRP8m.js → chunk-C_KEGoSE.js} +1 -1
- package/static/chunk-CmdZYpyj.js +1 -0
- package/static/chunk-DWTt0jUK.js +1 -0
- package/static/{chunk-C3E91izb.js → chunk-Dio1flWJ.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 +131 -0
- package/static/workspace.js +79 -79
- package/static/chunk-C8d9FvLk.js +0 -1
- package/static/chunk-h55PfL_M.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 "No active reviews";
|
|
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",
|
|
@@ -9583,13 +9704,20 @@ ${renderWorkspaceRail({
|
|
|
9583
9704
|
<main class="prb-index">
|
|
9584
9705
|
${ordered.length === 0 ? "" : `<div class="prb-index-summary">
|
|
9585
9706
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
9586
|
-
<
|
|
9707
|
+
<div class="prb-index-summary-actions">
|
|
9708
|
+
<button type="button" class="prb-index-select" data-collection-new><span aria-hidden="true">+</span> New collection</button>
|
|
9709
|
+
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
9710
|
+
</div>
|
|
9587
9711
|
</div>`}
|
|
9588
|
-
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9589
|
-
${
|
|
9712
|
+
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed), memberOf)}
|
|
9713
|
+
${renderSectionAt("pinned")}
|
|
9714
|
+
${ordered.length === 0 ? "" : collectionsArea(context, view.sectionsCollapsed)}
|
|
9715
|
+
${INDEX_SECTIONS.filter((section) => section !== "pinned").map(renderSectionAt).join("\n ")}
|
|
9590
9716
|
${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
9717
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
9592
|
-
|
|
9718
|
+
${ordered.length === 0 ? "" : renderCollectionsDialog()}
|
|
9719
|
+
${ordered.length === 0 ? "" : renderNewCollectionDialog()}
|
|
9720
|
+
<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
9721
|
</main>
|
|
9594
9722
|
</div>
|
|
9595
9723
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
@@ -9605,7 +9733,7 @@ ${renderWorkspaceRail({
|
|
|
9605
9733
|
* per risk level would be four more controls for a question ("show me only the risky ones") that
|
|
9606
9734
|
* sorting by recency already answers badly and that scanning answers well.
|
|
9607
9735
|
*/
|
|
9608
|
-
function renderControls$1(queued, authors, anyRequest) {
|
|
9736
|
+
function renderControls$1(queued, authors, anyRequest, anyCollection) {
|
|
9609
9737
|
return `<div class="prb-index-controls">
|
|
9610
9738
|
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
9611
9739
|
${renderAuthorFilter(authors)}
|
|
@@ -9618,10 +9746,29 @@ function renderControls$1(queued, authors, anyRequest) {
|
|
|
9618
9746
|
</select>
|
|
9619
9747
|
</label>
|
|
9620
9748
|
${anyRequest ? renderPrFilter() : ""}
|
|
9749
|
+
${renderCollectionsFilter(anyCollection)}
|
|
9621
9750
|
${renderClearFilters()}
|
|
9622
9751
|
</div>`;
|
|
9623
9752
|
}
|
|
9624
9753
|
/**
|
|
9754
|
+
* Which collections to show, any of several, and Uncollected for the reviews in none.
|
|
9755
|
+
*
|
|
9756
|
+
* A menu holding checkboxes rather than a select, because a select chooses one thing and this
|
|
9757
|
+
* chooses several, and because the list can be long enough to want its own search. The options are
|
|
9758
|
+
* filled in by the browser from the page's list of collections, so one made or renamed on the page
|
|
9759
|
+
* appears here without a reload. Hidden while there are no collections: a filter whose only choice
|
|
9760
|
+
* is Uncollected is not a choice.
|
|
9761
|
+
*/
|
|
9762
|
+
function renderCollectionsFilter(anyCollection) {
|
|
9763
|
+
return `<div class="prb-index-status prb-collections-filter" data-menu data-menu-form data-collections-filter${anyCollection ? "" : " hidden"}>
|
|
9764
|
+
<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>
|
|
9765
|
+
<div class="prb-menu-panel prb-collections-filter-panel" data-menu-panel hidden>
|
|
9766
|
+
<input type="search" class="prb-collections-filter-search" data-collections-filter-search placeholder="Find a collection" aria-label="Find a collection" spellcheck="false">
|
|
9767
|
+
<div class="prb-collections-filter-options" data-collections-filter-options role="group" aria-label="Collections"></div>
|
|
9768
|
+
</div>
|
|
9769
|
+
</div>`;
|
|
9770
|
+
}
|
|
9771
|
+
/**
|
|
9625
9772
|
* Puts the search and every filter back at once. Hidden until the browser finds one narrowing the
|
|
9626
9773
|
* list: the hash is the browser's to read, and without script there is nothing to clear.
|
|
9627
9774
|
*/
|
|
@@ -9671,6 +9818,144 @@ function renderAuthorFilter(authors) {
|
|
|
9671
9818
|
</label>`;
|
|
9672
9819
|
}
|
|
9673
9820
|
/**
|
|
9821
|
+
* The Collections area, between Pinned and Active: one disclosure per collection, each listing its
|
|
9822
|
+
* reviews that still need attention, with its Done reviews folded beneath.
|
|
9823
|
+
*
|
|
9824
|
+
* Exported for the routes that change collections, which answer with this markup so the browser can
|
|
9825
|
+
* swap the area in place: every card instance is drawn by `renderCard`, here and on a full load, and
|
|
9826
|
+
* never copied in the browser.
|
|
9827
|
+
*/
|
|
9828
|
+
/** Collection ids by lineage key, in collection order: what a card's `data-collections` says. */
|
|
9829
|
+
function membershipsByLineage(collections) {
|
|
9830
|
+
const memberOf = /* @__PURE__ */ new Map();
|
|
9831
|
+
for (const collection of collections) for (const key of collection.members) memberOf.set(key, [...memberOf.get(key) ?? [], collection.id]);
|
|
9832
|
+
return memberOf;
|
|
9833
|
+
}
|
|
9834
|
+
function renderCollectionsArea(view) {
|
|
9835
|
+
return collectionsArea(indexContext(view), view.sectionsCollapsed);
|
|
9836
|
+
}
|
|
9837
|
+
function collectionsArea(context, collapsed) {
|
|
9838
|
+
const { ordered, refs, collections, memberOf, activityOf, inProgressOnly, renderCards } = context;
|
|
9839
|
+
const registry = JSON.stringify(refs).replace(/</g, "\\u003c");
|
|
9840
|
+
const blocks = collections.map((collection) => {
|
|
9841
|
+
const members = ordered.filter((group) => (memberOf.get(group.key) ?? []).includes(collection.id));
|
|
9842
|
+
const place = (group) => collectionPlacement(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all");
|
|
9843
|
+
const drawn = members.filter((group) => !inProgressOnly.has(group.latest.id));
|
|
9844
|
+
const active = members.filter((group) => place(group) !== "completed");
|
|
9845
|
+
const completed = members.filter((group) => place(group) === "completed");
|
|
9846
|
+
return renderCollection(collection, {
|
|
9847
|
+
active: drawn.filter((group) => place(group) === "active").length,
|
|
9848
|
+
completed: drawn.filter((group) => place(group) === "completed").length,
|
|
9849
|
+
pinned: drawn.filter((group) => group.latest.pinned === true).length,
|
|
9850
|
+
members: members.length
|
|
9851
|
+
}, collection.members.length - members.length, collapsed, {
|
|
9852
|
+
active: renderCards(active, { collection: collection.id }, (group) => place(group) === void 0),
|
|
9853
|
+
completed: renderCards(completed, { collection: collection.id })
|
|
9854
|
+
});
|
|
9855
|
+
});
|
|
9856
|
+
return `<section class="prb-collections" data-collections-area aria-label="Collections"${collections.length === 0 ? " hidden" : ""}>
|
|
9857
|
+
<script type="application/json" data-collections-registry>${registry}<\/script>
|
|
9858
|
+
${blocks.join("\n ")}
|
|
9859
|
+
</section>`;
|
|
9860
|
+
}
|
|
9861
|
+
function renderCollection(collection, counts, omitted, collapsed, cards) {
|
|
9862
|
+
const id = escapeHtml$1(collection.id);
|
|
9863
|
+
const empty = collectionEmptyText(counts, false);
|
|
9864
|
+
return `<div class="prb-collection" data-collection="${id}">
|
|
9865
|
+
<details class="prb-index-section" data-section="${escapeHtml$1(collectionFold(collection.id))}"${sectionOpen(collectionFold(collection.id), collapsed) ? " open" : ""}>
|
|
9866
|
+
<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>
|
|
9867
|
+
<p class="prb-collection-empty" data-collection-empty${empty === void 0 ? " hidden" : ""}>${escapeHtml$1(empty ?? "")}</p>
|
|
9868
|
+
<div class="prb-cards" data-cards="${escapeHtml$1(collectionFold(collection.id))}">
|
|
9869
|
+
${cards.active}
|
|
9870
|
+
</div>
|
|
9871
|
+
${renderLoadMore$1(collectionFold(collection.id))}
|
|
9872
|
+
${omitted > 0 ? `<p class="prb-collection-empty" data-collection-omitted>${escapeHtml$1(omittedText(omitted))}</p>` : ""}
|
|
9873
|
+
<details class="prb-collection-completed" data-section="${escapeHtml$1(completedFold(collection.id))}"${sectionOpen(completedFold(collection.id), collapsed) ? " open" : ""}${counts.completed === 0 ? " hidden" : ""}>
|
|
9874
|
+
<summary class="prb-collection-completed-head" data-completed-label>${escapeHtml$1(completedLabel(counts.completed))}</summary>
|
|
9875
|
+
<div class="prb-cards" data-cards="${escapeHtml$1(completedFold(collection.id))}">
|
|
9876
|
+
${cards.completed}
|
|
9877
|
+
</div>
|
|
9878
|
+
${renderLoadMore$1(completedFold(collection.id))}
|
|
9879
|
+
</details>
|
|
9880
|
+
</details>
|
|
9881
|
+
${renderCollectionMenu(collection)}
|
|
9882
|
+
</div>`;
|
|
9883
|
+
}
|
|
9884
|
+
/**
|
|
9885
|
+
* A collection's own menu: rename it in place, or delete it.
|
|
9886
|
+
*
|
|
9887
|
+
* Deleting says what it does not do, which is the thing a reader worries about: the reviews stay.
|
|
9888
|
+
* It says what does change, too, because collected reviews are kept for longer and these go back to
|
|
9889
|
+
* the ordinary thirty days unless something else keeps them.
|
|
9890
|
+
*/
|
|
9891
|
+
function renderCollectionMenu(collection) {
|
|
9892
|
+
const id = escapeHtml$1(collection.id);
|
|
9893
|
+
return `<div class="prb-menu prb-collection-menu" data-menu data-menu-form>
|
|
9894
|
+
<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>
|
|
9895
|
+
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
9896
|
+
<div data-menu-items>
|
|
9897
|
+
<button type="button" class="prb-menu-item" data-collection-rename="${id}">Rename collection</button>
|
|
9898
|
+
<button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-delete="${id}">Delete collection</button>
|
|
9899
|
+
</div>
|
|
9900
|
+
<form class="prb-collection-rename" data-collection-rename-form="${id}" hidden>
|
|
9901
|
+
<label class="prb-menu-question" for="prb-rename-${id}">New name</label>
|
|
9902
|
+
<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">
|
|
9903
|
+
<p class="prb-collection-error" data-collection-error hidden role="alert"></p>
|
|
9904
|
+
<div class="prb-menu-buttons">
|
|
9905
|
+
<button type="submit" class="prb-menu-item">Save</button>
|
|
9906
|
+
<button type="button" class="prb-menu-item" data-collection-cancel>Cancel</button>
|
|
9907
|
+
</div>
|
|
9908
|
+
</form>
|
|
9909
|
+
<div class="prb-menu-confirm" data-menu-confirm hidden>
|
|
9910
|
+
<p class="prb-menu-question">${escapeHtml$1(`Delete the collection ${collection.name}?`)}</p>
|
|
9911
|
+
<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>
|
|
9912
|
+
<div class="prb-menu-buttons">
|
|
9913
|
+
<button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-confirm-delete="${id}">Delete</button>
|
|
9914
|
+
<button type="button" class="prb-menu-item" data-cancel-delete>Cancel</button>
|
|
9915
|
+
</div>
|
|
9916
|
+
</div>
|
|
9917
|
+
</div>
|
|
9918
|
+
</div>`;
|
|
9919
|
+
}
|
|
9920
|
+
/**
|
|
9921
|
+
* New collection, opened from beside Select. The description lives here, where somebody is deciding
|
|
9922
|
+
* what to make, rather than on the list for good.
|
|
9923
|
+
*/
|
|
9924
|
+
function renderNewCollectionDialog() {
|
|
9925
|
+
return `<dialog class="prb-dialog prb-collection-new-dialog" data-collection-new-dialog aria-labelledby="prb-collection-new-title">
|
|
9926
|
+
<form method="dialog" data-collection-new-form>
|
|
9927
|
+
<h2 id="prb-collection-new-title">New collection</h2>
|
|
9928
|
+
<p>Group reviews that belong together, such as one refactor or one release.</p>
|
|
9929
|
+
<label class="prb-field"><span>Collection name</span>
|
|
9930
|
+
<input type="text" data-collection-name-input placeholder="e.g. Refactor" maxlength="60" autocomplete="off" spellcheck="false" required>
|
|
9931
|
+
</label>
|
|
9932
|
+
<p class="prb-collection-error" data-collection-error hidden role="alert"></p>
|
|
9933
|
+
<div class="prb-dialog-actions">
|
|
9934
|
+
<button type="button" data-collection-cancel>Cancel</button>
|
|
9935
|
+
<button type="submit" class="prb-dialog-primary" data-collection-create disabled>Create</button>
|
|
9936
|
+
</div>
|
|
9937
|
+
</form>
|
|
9938
|
+
</dialog>`;
|
|
9939
|
+
}
|
|
9940
|
+
/**
|
|
9941
|
+
* Manage collections, for one card or for a selection. Built once; the browser fills the list from
|
|
9942
|
+
* the page's collections and the reviews it was opened for, because the boxes' states depend on
|
|
9943
|
+
* which reviews those are.
|
|
9944
|
+
*/
|
|
9945
|
+
function renderCollectionsDialog() {
|
|
9946
|
+
return `<dialog class="prb-dialog prb-collections-dialog" data-collections-dialog aria-labelledby="prb-collections-dialog-title">
|
|
9947
|
+
<h2 id="prb-collections-dialog-title">Collections</h2>
|
|
9948
|
+
<p data-collections-dialog-for></p>
|
|
9949
|
+
<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">
|
|
9950
|
+
<div class="prb-collections-dialog-list" data-collections-dialog-list role="group" aria-label="Collections"></div>
|
|
9951
|
+
<button type="button" class="prb-collections-dialog-create" data-collections-dialog-create hidden></button>
|
|
9952
|
+
<p class="prb-collection-error" data-collections-dialog-error hidden role="alert"></p>
|
|
9953
|
+
<div class="prb-dialog-actions">
|
|
9954
|
+
<button type="button" data-collections-dialog-close>Done</button>
|
|
9955
|
+
</div>
|
|
9956
|
+
</dialog>`;
|
|
9957
|
+
}
|
|
9958
|
+
/**
|
|
9674
9959
|
* One section's Load more, which the browser shows once that section holds more than it is showing.
|
|
9675
9960
|
*
|
|
9676
9961
|
* One per section rather than one pager for the page, so revealing older Done reviews never costs
|
|
@@ -9708,10 +9993,11 @@ function renderSection(section, groups, renderCards, open, shown = groups.length
|
|
|
9708
9993
|
* index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
|
|
9709
9994
|
* counts once here and once in its own section, which is the point.
|
|
9710
9995
|
*/
|
|
9711
|
-
function renderInProgress$1(operations, groups, positions, prStates, now, open) {
|
|
9996
|
+
function renderInProgress$1(operations, groups, positions, prStates, now, open, memberOf = /* @__PURE__ */ new Map()) {
|
|
9712
9997
|
if (operations.length === 0) return "";
|
|
9713
9998
|
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
9714
|
-
const
|
|
9999
|
+
const collections = operationCollections(operations, groups, memberOf);
|
|
10000
|
+
const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now, collections[operation.key] ?? [])).join("");
|
|
9715
10001
|
return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
|
|
9716
10002
|
<summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
|
|
9717
10003
|
<div class="prb-cards" data-operations>
|
|
@@ -9725,6 +10011,23 @@ function renderInProgress$1(operations, groups, positions, prStates, now, open)
|
|
|
9725
10011
|
* one, which is what that review's title, author and state still are: the filters then treat a
|
|
9726
10012
|
* re-review of a known pull request as that pull request, rather than as somebody unknown.
|
|
9727
10013
|
*/
|
|
10014
|
+
/**
|
|
10015
|
+
* The collections each piece of work's review is in, by the work's key: the lineage of whichever
|
|
10016
|
+
* review `reviewOf` finds, so a waiting row with no review yet still answers the Collections filter
|
|
10017
|
+
* the way its card will. Also what the collections routes send back, so a change of membership
|
|
10018
|
+
* reaches the In progress section without a reload.
|
|
10019
|
+
*/
|
|
10020
|
+
function operationCollections(operations, groups, memberOf) {
|
|
10021
|
+
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
10022
|
+
const lineage = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, group.key])));
|
|
10023
|
+
const answer = {};
|
|
10024
|
+
for (const operation of operations) {
|
|
10025
|
+
const review = reviewOf(operation, reviews);
|
|
10026
|
+
const key = review ? lineage.get(review.id) : void 0;
|
|
10027
|
+
answer[operation.key] = key === void 0 ? [] : [...memberOf.get(key) ?? []];
|
|
10028
|
+
}
|
|
10029
|
+
return answer;
|
|
10030
|
+
}
|
|
9728
10031
|
function reviewOf(operation, reviews) {
|
|
9729
10032
|
if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
|
|
9730
10033
|
const row = operation.row;
|
|
@@ -9746,7 +10049,7 @@ function reviewOf(operation, reviews) {
|
|
|
9746
10049
|
* A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
|
|
9747
10050
|
* for a slot has no review to open, so it is not a link.
|
|
9748
10051
|
*/
|
|
9749
|
-
function renderOperation(operation, review, positions, prStates, now) {
|
|
10052
|
+
function renderOperation(operation, review, positions, prStates, now, collections = []) {
|
|
9750
10053
|
const row = operation.row;
|
|
9751
10054
|
const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
|
|
9752
10055
|
const repoPath = review?.repoPath ?? row?.repoPath;
|
|
@@ -9777,7 +10080,7 @@ function renderOperation(operation, review, positions, prStates, now) {
|
|
|
9777
10080
|
${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
|
|
9778
10081
|
</p>`;
|
|
9779
10082
|
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)}">
|
|
10083
|
+
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
10084
|
${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
9782
10085
|
</article>`;
|
|
9783
10086
|
}
|
|
@@ -9802,6 +10105,7 @@ function renderBulkBar() {
|
|
|
9802
10105
|
<button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
|
|
9803
10106
|
<button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
|
|
9804
10107
|
<button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
|
|
10108
|
+
<button type="button" class="prb-bulk-button" data-bulk-collections disabled>Manage collections</button>
|
|
9805
10109
|
<button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
|
|
9806
10110
|
<button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
|
|
9807
10111
|
${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
|
|
@@ -9838,13 +10142,21 @@ function renderBulkBar() {
|
|
|
9838
10142
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
9839
10143
|
* than a rebuild of the list.
|
|
9840
10144
|
*/
|
|
9841
|
-
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false
|
|
10145
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false, instance = {
|
|
10146
|
+
placement: "home",
|
|
10147
|
+
memberOf: [],
|
|
10148
|
+
refs: [],
|
|
10149
|
+
hidden: false
|
|
10150
|
+
}) {
|
|
9842
10151
|
const review = group.latest;
|
|
10152
|
+
const placement = instance.placement === "home" ? "home" : `collection:${instance.placement.collection}`;
|
|
10153
|
+
const line = collectionsLine(instance.memberOf, instance.refs, instance.placement === "home" ? void 0 : instance.placement.collection);
|
|
10154
|
+
const hidden = inProgressOnly || instance.hidden;
|
|
9843
10155
|
const status = statusOf(review);
|
|
9844
10156
|
const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
|
|
9845
10157
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
9846
10158
|
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" : ""}>
|
|
10159
|
+
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
10160
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
9849
10161
|
<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
10162
|
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
@@ -9870,6 +10182,7 @@ function renderCard(group, now, activity, rank, analysis, queued, moved, prState
|
|
|
9870
10182
|
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
9871
10183
|
${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
|
|
9872
10184
|
${renderAnalysisState(analysis)}
|
|
10185
|
+
<span class="prb-card-collections" data-collections-line${line === void 0 ? " hidden" : ""}>${escapeHtml$1(line ?? "")}</span>
|
|
9873
10186
|
<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
10187
|
</p>
|
|
9875
10188
|
</a>
|
|
@@ -9910,7 +10223,8 @@ function renderCardMenu(group, followed, queued, status, activity) {
|
|
|
9910
10223
|
queue: queued ? "remove" : "add",
|
|
9911
10224
|
mark,
|
|
9912
10225
|
lifecycle,
|
|
9913
|
-
fetch: true
|
|
10226
|
+
fetch: true,
|
|
10227
|
+
collections: true
|
|
9914
10228
|
});
|
|
9915
10229
|
const all = `all ${count} reviews following ${followed}`;
|
|
9916
10230
|
return renderMenu({
|
|
@@ -9923,6 +10237,7 @@ function renderCardMenu(group, followed, queued, status, activity) {
|
|
|
9923
10237
|
mark,
|
|
9924
10238
|
lifecycle,
|
|
9925
10239
|
fetch: true,
|
|
10240
|
+
collections: true,
|
|
9926
10241
|
copy: "Copy latest review ID"
|
|
9927
10242
|
});
|
|
9928
10243
|
}
|
|
@@ -9953,6 +10268,7 @@ function renderMenu(item) {
|
|
|
9953
10268
|
${markItem}
|
|
9954
10269
|
${lifecycleItems}
|
|
9955
10270
|
${queueItem}
|
|
10271
|
+
${item.collections ? `<button type="button" class="prb-menu-item" data-collections-for="${id}">Manage collections…</button>` : ""}
|
|
9956
10272
|
<button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
|
|
9957
10273
|
<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
|
|
9958
10274
|
</div>
|
|
@@ -10363,14 +10679,15 @@ function writeChangeMapRegister(register) {
|
|
|
10363
10679
|
}, null, 2)}\n`);
|
|
10364
10680
|
} catch {}
|
|
10365
10681
|
}
|
|
10366
|
-
/**
|
|
10682
|
+
/**
|
|
10683
|
+
* Known sections and collection folds with a boolean only. A hand-edited key we do not draw is
|
|
10684
|
+
* dropped on the next write. A fold for a collection since deleted elsewhere is kept until
|
|
10685
|
+
* `forgetSections` is told, which costs one line of JSON and nothing on the page.
|
|
10686
|
+
*/
|
|
10367
10687
|
function readReviewSections(value) {
|
|
10368
10688
|
if (!value || typeof value !== "object") return {};
|
|
10369
10689
|
const sections = {};
|
|
10370
|
-
for (const section of
|
|
10371
|
-
const collapsed = value[section];
|
|
10372
|
-
if (typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10373
|
-
}
|
|
10690
|
+
for (const [section, collapsed] of Object.entries(value)) if (isFoldKey(section) && typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10374
10691
|
return Object.keys(sections).length === 0 ? {} : { reviewSections: sections };
|
|
10375
10692
|
}
|
|
10376
10693
|
/**
|
|
@@ -10392,6 +10709,19 @@ function writeSectionCollapsed(section, collapsed) {
|
|
|
10392
10709
|
}, null, 2)}\n`);
|
|
10393
10710
|
} catch {}
|
|
10394
10711
|
}
|
|
10712
|
+
/** Drop folds nothing will draw again: a deleted collection's. Writes nothing when none was stored. */
|
|
10713
|
+
function forgetSections(sections) {
|
|
10714
|
+
try {
|
|
10715
|
+
const current = readPreferences();
|
|
10716
|
+
const stored = current.reviewSections;
|
|
10717
|
+
if (!stored || !sections.some((section) => section in stored)) return;
|
|
10718
|
+
const reviewSections = Object.fromEntries(Object.entries(stored).filter(([section]) => !sections.includes(section)));
|
|
10719
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
10720
|
+
...current,
|
|
10721
|
+
reviewSections
|
|
10722
|
+
}, null, 2)}\n`);
|
|
10723
|
+
} catch {}
|
|
10724
|
+
}
|
|
10395
10725
|
/** As `readReviewSections`, for the Runs page. */
|
|
10396
10726
|
function readRunsSections(value) {
|
|
10397
10727
|
if (!value || typeof value !== "object") return {};
|
|
@@ -11164,6 +11494,25 @@ feature: E</pre>
|
|
|
11164
11494
|
<p>Deleting a review removes its record and the isolated checkouts it was made in. It never
|
|
11165
11495
|
touches your branch or its commits. Delete one from its menu, or tick reviews in the Reviews
|
|
11166
11496
|
list, one at a time or a whole branch at once, and delete them together.</p>`
|
|
11497
|
+
},
|
|
11498
|
+
{
|
|
11499
|
+
id: "collections",
|
|
11500
|
+
title: "Collections",
|
|
11501
|
+
body: `<p>A collection groups reviews that belong together, such as the pull requests of one
|
|
11502
|
+
refactor or one release. It says what belongs together; whether each review still needs your
|
|
11503
|
+
attention is still Active, Waiting or Done, so adding or removing a review never changes its
|
|
11504
|
+
state.</p>
|
|
11505
|
+
<p>Make one with New collection on the Reviews page, or from Manage collections in a card's menu.
|
|
11506
|
+
To add several reviews at once, choose Select, tick them, and choose Manage collections. A review
|
|
11507
|
+
can be in more than one collection. Ticking a collection adds every selected review to it and
|
|
11508
|
+
leaves their other collections alone.</p>
|
|
11509
|
+
<p>Each collection lists its reviews that are not Done. Marking one Done moves it under Show
|
|
11510
|
+
completed, in every collection it is in, and Move to active brings it back. A review in a
|
|
11511
|
+
collection is listed there rather than under Active, Waiting or Done. A pinned review stays under
|
|
11512
|
+
Pinned. The Collections filter shows chosen collections, or Uncollected for reviews in none.</p>
|
|
11513
|
+
<p>Reviews in a collection are kept for ninety days, like pinned and Done reviews, counted from
|
|
11514
|
+
when you last opened or marked them. Adding one to a collection doesn't restart that count.
|
|
11515
|
+
Deleting a collection keeps its reviews.</p>`
|
|
11167
11516
|
},
|
|
11168
11517
|
{
|
|
11169
11518
|
id: "several-pull-requests",
|
|
@@ -11217,8 +11566,8 @@ feature: E</pre>
|
|
|
11217
11566
|
automatically:</p>
|
|
11218
11567
|
<ul>
|
|
11219
11568
|
<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>
|
|
11569
|
+
<li><b>Reviews you kept</b> (saved, marked Done, in a collection, or on your review queue) are
|
|
11570
|
+
kept for ninety days instead.</li>
|
|
11222
11571
|
<li><b>Isolated checkouts</b> are removed six hours after their work finishes. See
|
|
11223
11572
|
<a href="#isolated-checkouts">Isolated checkouts</a>.</li>
|
|
11224
11573
|
<li><b>Runs</b>, the record on the Runs page of what ran and how it ended, are kept for seven
|
|
@@ -11395,6 +11744,50 @@ function renderNotificationRow(notification, gone) {
|
|
|
11395
11744
|
</li>`;
|
|
11396
11745
|
}
|
|
11397
11746
|
//#endregion
|
|
11747
|
+
//#region ../../packages/review-harness/src/workspace/collections_live.ts
|
|
11748
|
+
/**
|
|
11749
|
+
* Collections as they relate to reviews that actually exist.
|
|
11750
|
+
*
|
|
11751
|
+
* `collections.ts` stores lineage keys and must not learn to read the review store, for the cycle
|
|
11752
|
+
* `queue_live.ts` describes: the prune in `store.ts` imports it. Anything that asks both questions
|
|
11753
|
+
* lives here, which imports both and is imported by neither.
|
|
11754
|
+
*/
|
|
11755
|
+
/**
|
|
11756
|
+
* Drop members whose lineage has no review any more, deleted elsewhere or pruned by age, and store
|
|
11757
|
+
* the result if that changed anything. Read against every group, not the windowed list, so a branch
|
|
11758
|
+
* the recency window happens to hide keeps its place.
|
|
11759
|
+
*
|
|
11760
|
+
* Compacted again under the lock rather than written from what was read, so a member somebody added
|
|
11761
|
+
* since is kept. On a failed write the compacted copy still stands in, so the page draws what exists.
|
|
11762
|
+
*/
|
|
11763
|
+
function settleCollections(groups) {
|
|
11764
|
+
const live = new Set(groups.map((group) => group.key));
|
|
11765
|
+
const compact = (collections) => collections.some((collection) => collection.members.some((key) => !live.has(key))) ? collections.map((collection) => ({
|
|
11766
|
+
...collection,
|
|
11767
|
+
members: collection.members.filter((key) => live.has(key))
|
|
11768
|
+
})) : null;
|
|
11769
|
+
const read = readCollections();
|
|
11770
|
+
const compacted = compact(read);
|
|
11771
|
+
if (compacted === null) return read;
|
|
11772
|
+
return updateCollections((current) => compact(current)) ?? compacted;
|
|
11773
|
+
}
|
|
11774
|
+
/**
|
|
11775
|
+
* The lineage each review id belongs to, for the routes: the page sends the ids on its cards, and
|
|
11776
|
+
* the lineage is the server's to derive. An id with no review, or one that cannot be summarised, is
|
|
11777
|
+
* left out, and the caller says so.
|
|
11778
|
+
*/
|
|
11779
|
+
function lineageKeysFor(ids) {
|
|
11780
|
+
const keys = /* @__PURE__ */ new Map();
|
|
11781
|
+
for (const id of new Set(ids)) {
|
|
11782
|
+
const workspace = loadWorkspace(id);
|
|
11783
|
+
if (!workspace) continue;
|
|
11784
|
+
try {
|
|
11785
|
+
keys.set(id, lineageKey(summarise(workspace)));
|
|
11786
|
+
} catch {}
|
|
11787
|
+
}
|
|
11788
|
+
return keys;
|
|
11789
|
+
}
|
|
11790
|
+
//#endregion
|
|
11398
11791
|
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11399
11792
|
/**
|
|
11400
11793
|
* The local Git working folders PR Review Buddy can find right now.
|
|
@@ -12610,6 +13003,7 @@ function reviewsRevision(live = analysisStates(), operations = liveOperations())
|
|
|
12610
13003
|
}
|
|
12611
13004
|
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
13005
|
hash.update(`queue:${queueStamp()}\n`);
|
|
13006
|
+
hash.update(`collections:${collectionsStamp()}\n`);
|
|
12613
13007
|
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
12614
13008
|
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
12615
13009
|
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 +15000,8 @@ async function handle(req, res, options) {
|
|
|
14606
15000
|
if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
|
|
14607
15001
|
if (url.pathname === "/sections") return handleSections(req, res, options.indexToken);
|
|
14608
15002
|
if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
|
|
15003
|
+
if (url.pathname === "/collections") return handleCollections(req, res, options.indexToken);
|
|
15004
|
+
if (url.pathname === "/collections/members") return handleCollectionMembers(req, res, options.indexToken);
|
|
14609
15005
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
14610
15006
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
14611
15007
|
if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
|
|
@@ -14705,18 +15101,44 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
14705
15101
|
}
|
|
14706
15102
|
const cookie = indexCookieHeader(indexToken);
|
|
14707
15103
|
if (onward) return redirect(res, onward, cookie);
|
|
15104
|
+
sendHtml(res, 200, renderReviewsPage(indexView()), cookie);
|
|
15105
|
+
}
|
|
15106
|
+
/**
|
|
15107
|
+
* Which cards a page is drawn with: each branch, by its newest review. Equality only. Changes when a
|
|
15108
|
+
* branch is analysed again, or a card is added or deleted, which are the changes that make a
|
|
15109
|
+
* fragment drawn now wrong for a page drawn then. Nothing else moves it.
|
|
15110
|
+
*/
|
|
15111
|
+
function cardsSnapshot(groups) {
|
|
15112
|
+
const hash = createHash("sha1");
|
|
15113
|
+
for (const line of groups.map((group) => `${group.key}\t${group.latest.id}`).sort()) hash.update(`${line}\n`);
|
|
15114
|
+
return hash.digest("hex").slice(0, 16);
|
|
15115
|
+
}
|
|
15116
|
+
/**
|
|
15117
|
+
* Everything the reviews index is drawn from, read in one pass.
|
|
15118
|
+
*
|
|
15119
|
+
* Shared by the page and by the collections routes, which answer with the Collections area drawn
|
|
15120
|
+
* from the same view, so a card in a collection after a change is the card a reload would draw.
|
|
15121
|
+
*/
|
|
15122
|
+
function indexView() {
|
|
14708
15123
|
const states = analysisStates();
|
|
14709
15124
|
const operations = liveOperations();
|
|
14710
15125
|
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
14711
15126
|
const everyGroup = groupByLineage(reviews);
|
|
14712
15127
|
const queue = settleQueue(everyGroup);
|
|
15128
|
+
const collections = settleCollections(everyGroup);
|
|
14713
15129
|
const kept = indexKeptIds(reviews, queue);
|
|
14714
15130
|
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
|
|
15131
|
+
const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup, {
|
|
15132
|
+
keys: new Set(collections.flatMap((collection) => collection.members)),
|
|
15133
|
+
limit: MAX_COLLECTED_BRANCHES
|
|
15134
|
+
});
|
|
14716
15135
|
const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
|
|
14717
|
-
|
|
15136
|
+
const live = queue.filter((id) => rendered.has(id));
|
|
15137
|
+
return {
|
|
14718
15138
|
groups,
|
|
14719
|
-
|
|
15139
|
+
snapshot: cardsSnapshot(groups),
|
|
15140
|
+
queue: live,
|
|
15141
|
+
collections,
|
|
14720
15142
|
analyses: states,
|
|
14721
15143
|
operations,
|
|
14722
15144
|
moved: movedReviews(),
|
|
@@ -14727,7 +15149,7 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
14727
15149
|
driving: isDriving(),
|
|
14728
15150
|
theme: readPreferences().theme,
|
|
14729
15151
|
sectionsCollapsed: readPreferences().reviewSections
|
|
14730
|
-
}
|
|
15152
|
+
};
|
|
14731
15153
|
}
|
|
14732
15154
|
/**
|
|
14733
15155
|
* Where `?go=` is allowed to send someone, which is one shape and no other.
|
|
@@ -15381,7 +15803,7 @@ async function handleSections(req, res, indexToken) {
|
|
|
15381
15803
|
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15382
15804
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15383
15805
|
const body = await readJson(req);
|
|
15384
|
-
if (!
|
|
15806
|
+
if (!isFoldKey(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
|
|
15385
15807
|
writeSectionCollapsed(body.section, body.collapsed);
|
|
15386
15808
|
send(res, 200, { ok: true });
|
|
15387
15809
|
}
|
|
@@ -15450,6 +15872,102 @@ function addMany(res, ids) {
|
|
|
15450
15872
|
});
|
|
15451
15873
|
}
|
|
15452
15874
|
/**
|
|
15875
|
+
* Making, renaming and deleting collections, from the Reviews page.
|
|
15876
|
+
*
|
|
15877
|
+
* The index cookie, like the queue: this is the list acting on itself. Every answer that changed
|
|
15878
|
+
* something carries the collections as stored and the Collections area redrawn from them, so the
|
|
15879
|
+
* page swaps in exactly what a reload would show rather than building cards of its own.
|
|
15880
|
+
*/
|
|
15881
|
+
async function handleCollections(req, res, indexToken) {
|
|
15882
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15883
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15884
|
+
const body = await readJson(req);
|
|
15885
|
+
const action = body?.action;
|
|
15886
|
+
const name = typeof body?.name === "string" ? body.name : "";
|
|
15887
|
+
const id = isCollectionId(body?.id) ? body.id : null;
|
|
15888
|
+
const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
|
|
15889
|
+
if (action === "create") return answerCollections(res, createCollection(name), [], snapshot);
|
|
15890
|
+
if (action === "rename" && id) return answerCollections(res, renameCollection(id, name), [], snapshot);
|
|
15891
|
+
if (action === "delete" && id) {
|
|
15892
|
+
const outcome = deleteCollection(id);
|
|
15893
|
+
if (outcome.ok) forgetSections([collectionFold(id), completedFold(id)]);
|
|
15894
|
+
return answerCollections(res, outcome, [], snapshot);
|
|
15895
|
+
}
|
|
15896
|
+
send(res, 400, { error: "Unknown collections action." });
|
|
15897
|
+
}
|
|
15898
|
+
/**
|
|
15899
|
+
* Putting reviews into one collection, or taking them out of it: one card's menu or the bulk bar.
|
|
15900
|
+
*
|
|
15901
|
+
* The page sends review ids, the ids on its cards, and the lineage each belongs to is derived here,
|
|
15902
|
+
* because a lineage is what a collection holds and the server is the only side that can say which.
|
|
15903
|
+
* Only the named collection changes, whatever else the reviews belong to. `create` makes the
|
|
15904
|
+
* collection first, by name, so "Create Refactor" from the panel is one request and cannot leave an
|
|
15905
|
+
* empty collection behind a failed assignment without saying so.
|
|
15906
|
+
*/
|
|
15907
|
+
async function handleCollectionMembers(req, res, indexToken) {
|
|
15908
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15909
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15910
|
+
const body = await readJson(req);
|
|
15911
|
+
const action = body?.action;
|
|
15912
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((one) => typeof one === "string") : [];
|
|
15913
|
+
if (action !== "add" && action !== "remove" || ids.length === 0) return send(res, 400, { error: "Say which reviews, and whether to add or remove them." });
|
|
15914
|
+
const keys = lineageKeysFor(ids);
|
|
15915
|
+
const gone = ids.filter((one) => !keys.has(one));
|
|
15916
|
+
const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
|
|
15917
|
+
if (action === "add" && typeof body?.create === "string" && !isCollectionId(body?.collection)) {
|
|
15918
|
+
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." });
|
|
15919
|
+
return answerCollections(res, createWithMembers(body.create, [...keys.values()]), [], snapshot);
|
|
15920
|
+
}
|
|
15921
|
+
const collection = isCollectionId(body?.collection) ? body.collection : null;
|
|
15922
|
+
if (collection === null) return send(res, 400, { error: "Say which collection." });
|
|
15923
|
+
answerCollections(res, setMembership([...keys.values()], collection, action), gone.map((one) => ({
|
|
15924
|
+
id: one,
|
|
15925
|
+
error: "A review you selected has been deleted."
|
|
15926
|
+
})), snapshot);
|
|
15927
|
+
}
|
|
15928
|
+
/**
|
|
15929
|
+
* The answer every collections route gives: what is stored, and the area drawn from it, or why not.
|
|
15930
|
+
*
|
|
15931
|
+
* **Only for the page it would fit.** The area's cards and the memberships are keyed by each
|
|
15932
|
+
* branch's newest review, and a page drawn before a branch was analysed again, or before one was
|
|
15933
|
+
* added or deleted, holds cards under other ids. Swapping a fragment drawn from today's reviews into
|
|
15934
|
+
* that page would mix the two. So the page says which cards it was drawn with (`snapshot`, from
|
|
15935
|
+
* `cardsSnapshot`), and when that is not what the server would draw now the answer is `stale`, with
|
|
15936
|
+
* no fragment: the change itself is stored either way, by lineage, and the page reloads to show it.
|
|
15937
|
+
*/
|
|
15938
|
+
function answerCollections(res, outcome, refused, snapshot) {
|
|
15939
|
+
if (!outcome.ok) {
|
|
15940
|
+
if (outcome.reason === "missing") return send(res, 404, { error: "That collection has been deleted." });
|
|
15941
|
+
if (outcome.reason === "full") return send(res, 409, { error: "That is more than a collection can hold." });
|
|
15942
|
+
if (outcome.reason === "unsaved") return send(res, 500, { error: "The collections could not be saved." });
|
|
15943
|
+
return send(res, 409, {
|
|
15944
|
+
error: nameProblemText(outcome.reason),
|
|
15945
|
+
reason: outcome.reason
|
|
15946
|
+
});
|
|
15947
|
+
}
|
|
15948
|
+
const view = indexView();
|
|
15949
|
+
const collections = (view.collections ?? []).map(({ id, name }) => ({
|
|
15950
|
+
id,
|
|
15951
|
+
name
|
|
15952
|
+
}));
|
|
15953
|
+
if (snapshot === "" || snapshot !== view.snapshot) return send(res, 200, {
|
|
15954
|
+
stale: true,
|
|
15955
|
+
collections,
|
|
15956
|
+
...outcome.id === void 0 ? {} : { id: outcome.id },
|
|
15957
|
+
refused
|
|
15958
|
+
});
|
|
15959
|
+
const memberOf = {};
|
|
15960
|
+
for (const group of view.groups) memberOf[group.latest.id] = (view.collections ?? []).filter((one) => one.members.includes(group.key)).map((one) => one.id);
|
|
15961
|
+
send(res, 200, {
|
|
15962
|
+
collections,
|
|
15963
|
+
...outcome.id === void 0 ? {} : { id: outcome.id },
|
|
15964
|
+
memberOf,
|
|
15965
|
+
operations: operationCollections(view.operations ?? [], view.groups, membershipsByLineage(view.collections ?? [])),
|
|
15966
|
+
area: renderCollectionsArea(view),
|
|
15967
|
+
refused
|
|
15968
|
+
});
|
|
15969
|
+
}
|
|
15970
|
+
/**
|
|
15453
15971
|
* Delete one review, or everything on one card.
|
|
15454
15972
|
*
|
|
15455
15973
|
* Authorised the same way pinning is, and for the same reason: this is the list acting on itself,
|
|
@@ -15606,6 +16124,13 @@ var MAX_LISTED_BRANCHES = 50;
|
|
|
15606
16124
|
*/
|
|
15607
16125
|
var MAX_KEPT_BRANCHES = 50;
|
|
15608
16126
|
/**
|
|
16127
|
+
* How many collected branches the list draws, in a window of their own beside the two above, so a
|
|
16128
|
+
* collection never competes with Pinned or the queue for a place. One collection's worth: a single
|
|
16129
|
+
* collection is always drawn whole, and only collections holding more than this between them leave
|
|
16130
|
+
* their oldest members out, which each collection then says.
|
|
16131
|
+
*/
|
|
16132
|
+
var MAX_COLLECTED_BRANCHES = 500;
|
|
16133
|
+
/**
|
|
15609
16134
|
* A finished review's workspace was served.
|
|
15610
16135
|
*
|
|
15611
16136
|
* **This counts page serves, not people and not sessions.** A reload, a restored tab, the CLI
|