prreviewbuddy 0.31.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/dist/{delete_review-C2wax0ED.js → delete_review-TNIFNct3.js} +249 -7
- package/dist/main.js +25 -13
- package/dist/server.js +466 -75
- package/package.json +1 -1
- package/static/chunk-B80S6zbH.js +4 -4
- package/static/chunk-CBxYYqda.js +1 -0
- package/static/chunk-CUFGKuJi.js +1 -0
- package/static/chunk-X60fsCZA.js +1 -0
- package/static/notifications.js +1 -0
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +848 -412
- package/static/workspace.js +41 -41
- package/static/chunk-CIsM3PMS.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as feedbackUrl, $t as reviewerDispositions, A as markRead, At as loadWorkspace, Bt as workspaceRevision, C as recordTelemetryUploadConsent, Ct as isClaimed, D as headline, Dt as latestKissRun, E as eventName, Et as indexReviewGroups, F as EXPLAIN_SIMPLY_PROMPT, Ft as reviewedCommit, Gt as queueStamp, H as readIndexToken, Ht as dequeueOnComplete, I as ReviewBeingDeletedError, Jt as doneVerb, K as readServerRecord, Kt as readQueue, L as MANAGED_ROOT, Lt as saveWorkspace, M as readNotification, N as unreadCount$1, Nt as previousKissResult, O as listNotifications, P as withUsageRecorded, Pt as recentWorkspaces, Qt as questionsOutstanding, R as readMarker, S as record, T as announceJob, Ut as queueAdd, V as relativeTime$1, Vt as workspaceStamps, W as clearServerRecord, Wt as queueRemove, X as writeServerRecord, Xt as isQuestionOutstanding, Yt as isIssueOutstanding, Z as BUILD_VERSION, Zt as issuesOutstanding, _ as startKissJob, _n as AgentCancelledError, a as wasBlocked, at as statedWorkspacePort, bt as purposeOf, c as updateReview, cn as readAgentPreference, ct as fillFileUrlTemplate, d as askCheckout, dn as STORE_ROOT, dt as allJobs, en as processDiscussion, et as DEFAULT_WORKSPACE_PORT, f as UpdateAlreadyRunningError, fn as AGENT_IDS, ft as endedAt, gt as saveJob, h as agentEnvOf, hn as agentFor, ht as loadJob, i as liveJobsFor, in as git, it as resolveWorkspacePort, j as pruneNotifications, k as markAllRead, kt as lineagePosition, l as refreshPrContext, ln as writeAgentPreference, lt as checkCodeFreshness, mt as isTerminal, n as deleteReview, nt as MIN_WORKSPACE_PORT, o as discardJob, on as resolveModel, ot as writePortPreference, p as liveUpdateFor, pt as fail, qt as writeQueue, r as lineageIds, rt as parseWorkspacePort, s as RefreshUnavailableError, sn as clearAgentPreference, t as deleteLineage, tn as describeAuthorship, tt as MAX_WORKSPACE_PORT, u as isUnchanged, ut as checkReview, vn as AgentUnavailableError, vt as phasesFor, w as telemetryUploadConsent, wt as followedRefName, x as readEvents, y as runJob, yt as progressSteps, z as removeWorktree, zt as touchWorkspace } from "./delete_review-TNIFNct3.js";
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -250,6 +250,7 @@ var PATHS = {
|
|
|
250
250
|
prcontext: "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
|
|
251
251
|
recommendations: "<path d=\"M9 18h6M10 22h4\"/><path d=\"M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2z\"/>",
|
|
252
252
|
reviews: "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"/>",
|
|
253
|
+
notifications: "<path d=\"M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9\"/><path d=\"M13.7 21a2 2 0 0 1-3.4 0\"/>",
|
|
253
254
|
settings: "<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z\"/>",
|
|
254
255
|
feedback: "<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>",
|
|
255
256
|
stop: "<path d=\"M12 2v10\"/><path d=\"M18.4 6.6a9 9 0 1 1-12.8 0\"/>"
|
|
@@ -267,6 +268,122 @@ function railIcon(icon) {
|
|
|
267
268
|
function iconSvg(icon) {
|
|
268
269
|
return `<svg viewBox="0 0 24 24" focusable="false" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${PATHS[icon]}</svg>`;
|
|
269
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Which new notifications become toasts, and how many are left over.
|
|
273
|
+
*
|
|
274
|
+
* Never more than `MAX_TOASTS` toasts from one poll, the overflow toast included: past the cap, the
|
|
275
|
+
* last slot says how many more there are rather than one of them being dropped without a word.
|
|
276
|
+
*/
|
|
277
|
+
function splitForToasts(fresh) {
|
|
278
|
+
if (fresh.length <= 3) return {
|
|
279
|
+
shown: [...fresh],
|
|
280
|
+
more: 0
|
|
281
|
+
};
|
|
282
|
+
const shown = fresh.slice(0, 2);
|
|
283
|
+
return {
|
|
284
|
+
shown,
|
|
285
|
+
more: fresh.length - shown.length
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function unreadLabel(unread) {
|
|
289
|
+
return unread === 0 ? "Nothing unread" : `${unread} unread`;
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region ../../packages/review-harness/src/workspace/workspace_nav.ts
|
|
293
|
+
/**
|
|
294
|
+
* The workspace's own places, and the one model every page draws them from.
|
|
295
|
+
*
|
|
296
|
+
* A review page adds its review-level groups above these; every other workspace page draws these
|
|
297
|
+
* alone. There is no second list: the rail, the narrow header and the review page's rail foot all
|
|
298
|
+
* read `WORKSPACE_PLACES`, so a place added here appears everywhere and a place renamed here is
|
|
299
|
+
* renamed everywhere.
|
|
300
|
+
*/
|
|
301
|
+
var WORKSPACE_PLACES = [
|
|
302
|
+
{
|
|
303
|
+
place: "reviews",
|
|
304
|
+
href: "/",
|
|
305
|
+
icon: "reviews",
|
|
306
|
+
label: "Reviews"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
place: "notifications",
|
|
310
|
+
href: "/notifications",
|
|
311
|
+
icon: "notifications",
|
|
312
|
+
label: "Notifications"
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
place: "settings",
|
|
316
|
+
href: "/settings",
|
|
317
|
+
icon: "settings",
|
|
318
|
+
label: "Settings"
|
|
319
|
+
}
|
|
320
|
+
];
|
|
321
|
+
/**
|
|
322
|
+
* The count beside Notifications. Always rendered, hidden at zero, so the page can reveal it when
|
|
323
|
+
* `/revision` or Mark all read reports a new number. See `client/unread_counts.ts`.
|
|
324
|
+
*/
|
|
325
|
+
function unreadCount(unread, style) {
|
|
326
|
+
const className = style === "rail" ? "prb-rail-count prb-rail-unread" : "prb-unread-badge";
|
|
327
|
+
const text = unread === 0 ? "" : style === "rail" ? unreadLabel(unread) : String(unread);
|
|
328
|
+
return `<span class="${className}" data-unread-count data-unread-style="${style}"${unread === 0 ? " hidden" : ""}>${text}</span>`;
|
|
329
|
+
}
|
|
330
|
+
/** The workspace rows, for a rail group. */
|
|
331
|
+
function renderWorkspaceNav(options) {
|
|
332
|
+
return WORKSPACE_PLACES.map((item) => `<a class="prb-rail-item prb-rail-link" href="${item.href}"${item.place === options.current ? " aria-current=\"true\"" : ""}>
|
|
333
|
+
${railIcon(item.icon)}
|
|
334
|
+
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(item.label)}</span></span>
|
|
335
|
+
${item.place === "notifications" ? unreadCount(options.unread, "rail") : ""}
|
|
336
|
+
</a>`).join("");
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Stop server. Operational rather than navigation, and it ends every open review, so it sits apart
|
|
340
|
+
* from the places at the foot of the rail. Pair it with `renderShutdownDialog` on the same page.
|
|
341
|
+
*/
|
|
342
|
+
function renderStopServer() {
|
|
343
|
+
return `<button type="button" class="prb-rail-item prb-rail-link prb-rail-danger" data-shutdown>
|
|
344
|
+
${railIcon("stop")}
|
|
345
|
+
<span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
|
|
346
|
+
</button>`;
|
|
347
|
+
}
|
|
348
|
+
function renderShutdownDialog() {
|
|
349
|
+
return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
|
|
350
|
+
<h2 id="prb-shutdown-title">Stop the workspace server?</h2>
|
|
351
|
+
<p>One server serves every review on this machine, so <b>every open workspace stops</b>, not just this one.</p>
|
|
352
|
+
<p class="prb-dialog-reassure">Nothing is deleted. Your reviews stay on disk for thirty days, and their links keep working. Run <code>${escapeHtml$1(wayBackSentence())}</code> to start the server again.</p>
|
|
353
|
+
<div class="prb-dialog-actions">
|
|
354
|
+
<button type="button" data-shutdown-cancel>Cancel</button>
|
|
355
|
+
<button type="button" class="prb-dialog-danger" data-shutdown-confirm>Stop server</button>
|
|
356
|
+
</div>
|
|
357
|
+
</dialog>`;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The feedback form, in a new tab. What it carries is decided in `version.ts`: version, client, OS,
|
|
361
|
+
* and deliberately not the repository or its path.
|
|
362
|
+
*/
|
|
363
|
+
function renderSendFeedback() {
|
|
364
|
+
return `<a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
|
|
365
|
+
${railIcon("feedback")}
|
|
366
|
+
<span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
|
|
367
|
+
</a>`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The whole rail of a workspace page that is not a review: the places at the top, then Send
|
|
371
|
+
* feedback and Stop server at the foot, in the order a review page's rail ends with them.
|
|
372
|
+
*/
|
|
373
|
+
function renderWorkspaceRail(options) {
|
|
374
|
+
return `<nav class="prb-rail" aria-label="Workspace">
|
|
375
|
+
<div class="prb-rail-group">${renderWorkspaceNav(options)}</div>
|
|
376
|
+
<div class="prb-rail-group prb-rail-foot">${renderSendFeedback()}</div>
|
|
377
|
+
<div class="prb-rail-group prb-rail-ops">${renderStopServer()}</div>
|
|
378
|
+
</nav>`;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The rail's links in the header, shown only while the rail is hidden by a narrow window. Without it
|
|
382
|
+
* a narrow window would have no way to Notifications, Settings or the feedback form at all.
|
|
383
|
+
*/
|
|
384
|
+
function renderHeaderNav(options) {
|
|
385
|
+
return `<nav class="prb-header-nav" aria-label="Workspace">${WORKSPACE_PLACES.map((item) => `<a class="prb-feedback${item.place === "notifications" ? " prb-notifications-link" : ""}" href="${item.href}"${item.place === options.current ? " aria-current=\"true\"" : ""}>${escapeHtml$1(item.label)}${item.place === "notifications" ? unreadCount(options.unread, "badge") : ""}</a>`).join("")}<a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a></nav>`;
|
|
386
|
+
}
|
|
270
387
|
//#endregion
|
|
271
388
|
//#region ../../packages/review-harness/src/domain/utils/file_path.ts
|
|
272
389
|
/**
|
|
@@ -1282,13 +1399,13 @@ function discussionDetails(session) {
|
|
|
1282
1399
|
if (reviewThreads > 0) {
|
|
1283
1400
|
const outdated = stats.outdatedThreads ?? 0;
|
|
1284
1401
|
const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
|
|
1285
|
-
parts.push(`${reviewThreads} ${plural(reviewThreads, "review thread")} (${state})`);
|
|
1402
|
+
parts.push(`${reviewThreads} ${plural$1(reviewThreads, "review thread")} (${state})`);
|
|
1286
1403
|
}
|
|
1287
|
-
if (conversation > 0) parts.push(`${conversation} ${plural(conversation, "conversation comment")}`);
|
|
1288
|
-
if (decisions > 0) parts.push(`${decisions} ${plural(decisions, "review decision")}`);
|
|
1404
|
+
if (conversation > 0) parts.push(`${conversation} ${plural$1(conversation, "conversation comment")}`);
|
|
1405
|
+
if (decisions > 0) parts.push(`${decisions} ${plural$1(decisions, "review decision")}`);
|
|
1289
1406
|
return parts;
|
|
1290
1407
|
}
|
|
1291
|
-
function plural(count, word) {
|
|
1408
|
+
function plural$1(count, word) {
|
|
1292
1409
|
return count === 1 ? word : `${word}s`;
|
|
1293
1410
|
}
|
|
1294
1411
|
function renderExistingDiscussion(ref) {
|
|
@@ -1337,6 +1454,17 @@ function deriveSectionStatuses(sections, completed) {
|
|
|
1337
1454
|
const active = nextRecommendedSection(sections, completed);
|
|
1338
1455
|
return new Map(sections.map((s) => [s.id, completed.has(s.id) ? "completed" : s.id === active?.id ? "active" : "pending"]));
|
|
1339
1456
|
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Roughly how long one theme takes, for the rail.
|
|
1459
|
+
*
|
|
1460
|
+
* The same weight ranges the theme card quotes, reduced to a single number because a rail row has
|
|
1461
|
+
* room for one. The 1-minute floor exists for the same reason the whole-review estimate has a
|
|
1462
|
+
* 5-minute one: "~0 min" reads as "skip this".
|
|
1463
|
+
*/
|
|
1464
|
+
function themeMinutes(section) {
|
|
1465
|
+
const [min, max] = estimateMinutesForWeight(section.reviewWeight);
|
|
1466
|
+
return Math.max(1, Math.round((min + max) / 2));
|
|
1467
|
+
}
|
|
1340
1468
|
function remainingMinutes(sections, completed) {
|
|
1341
1469
|
const outstanding = sections.filter((s) => !completed.has(s.id));
|
|
1342
1470
|
if (outstanding.length === 0) return 0;
|
|
@@ -1383,6 +1511,29 @@ function renderGuideProgressSummary(sections, completed) {
|
|
|
1383
1511
|
</div>
|
|
1384
1512
|
`;
|
|
1385
1513
|
}
|
|
1514
|
+
/**
|
|
1515
|
+
* The theme's header: a disclosure button where it can fold something away, a heading where it
|
|
1516
|
+
* cannot.
|
|
1517
|
+
*
|
|
1518
|
+
* The two differ only in the element and the arrow. Splitting them is what keeps a host that
|
|
1519
|
+
* shows one theme at a time from shipping a button whose only reachable outcome is an empty pane,
|
|
1520
|
+
* without either host describing a theme differently from the other.
|
|
1521
|
+
*/
|
|
1522
|
+
function renderSectionHeader(section, badges, statParts, collapsible) {
|
|
1523
|
+
const inside = `
|
|
1524
|
+
<div class="pr-helper-guide-section-badges">
|
|
1525
|
+
${badges}
|
|
1526
|
+
<span class="pr-helper-guide-badge pr-helper-attention-badge pr-helper-attention-${section.reviewAttention}">${escapeHtml$1(SECTION_ATTENTION_LABELS[section.reviewAttention])}</span>
|
|
1527
|
+
</div>
|
|
1528
|
+
<div class="pr-helper-guide-section-header-top">
|
|
1529
|
+
${collapsible ? "<span class=\"pr-helper-guide-disclosure\">▼</span>" : ""}
|
|
1530
|
+
<span class="pr-helper-guide-section-title">${escapeHtml$1(section.title)}</span>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div class="pr-helper-guide-meta-row" title="Review weight ${section.reviewWeight}/5">${escapeHtml$1(statParts.join(" · "))}</div>`;
|
|
1533
|
+
return collapsible ? `<button type="button" class="pr-helper-guide-section-header" data-guide-toggle="${escapeHtml$1(section.id)}">${inside}
|
|
1534
|
+
</button>` : `<div class="pr-helper-guide-section-header pr-helper-guide-section-header-static">${inside}
|
|
1535
|
+
</div>`;
|
|
1536
|
+
}
|
|
1386
1537
|
function renderGuideSectionCard(section, counts, activeGuidedSectionId, status, activeBadgeLabel, options) {
|
|
1387
1538
|
const files = section.files.map((f) => {
|
|
1388
1539
|
const focus = f.focus ? `<div class="pr-helper-guide-file-focus">${renderInlineText(f.focus)}</div>` : "";
|
|
@@ -1398,31 +1549,21 @@ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status,
|
|
|
1398
1549
|
statParts.push(`~${minMinutes}-${maxMinutes} min`);
|
|
1399
1550
|
const isActive = activeGuidedSectionId === section.id;
|
|
1400
1551
|
const isCompleted = status === "completed";
|
|
1552
|
+
const collapsible = options?.collapsible !== false;
|
|
1401
1553
|
const check = isCompleted ? `<span class="pr-helper-guide-check" aria-hidden="true">✓</span>` : "";
|
|
1402
1554
|
const statusBadge = isCompleted ? `<span class="pr-helper-guide-badge pr-helper-completed-badge">Completed</span>` : status === "active" ? `<span class="pr-helper-guide-badge pr-helper-start-here-badge">${escapeHtml$1(activeBadgeLabel)}</span>` : "";
|
|
1403
1555
|
const undo = isCompleted ? `<button type="button" class="pr-helper-guide-uncomplete" data-guide-uncomplete="${escapeHtml$1(section.id)}">Mark as not reviewed</button>` : "";
|
|
1404
1556
|
return `
|
|
1405
1557
|
<section class="pr-helper-guide-section${isCompleted ? " pr-helper-guide-section-completed" : ""}${options?.collapsedSections ? " pr-helper-guide-section-collapsed" : ""}" id="pr-helper-guide-section-${escapeHtml$1(section.id)}" data-guide-section-card="${escapeHtml$1(section.id)}">
|
|
1406
|
-
|
|
1407
|
-
<div class="pr-helper-guide-section-badges">
|
|
1408
|
-
${check}
|
|
1409
|
-
${statusBadge}
|
|
1410
|
-
<span class="pr-helper-guide-badge pr-helper-attention-badge pr-helper-attention-${section.reviewAttention}">${escapeHtml$1(SECTION_ATTENTION_LABELS[section.reviewAttention])}</span>
|
|
1411
|
-
</div>
|
|
1412
|
-
<div class="pr-helper-guide-section-header-top">
|
|
1413
|
-
<span class="pr-helper-guide-disclosure">▼</span>
|
|
1414
|
-
<span class="pr-helper-guide-section-title">${escapeHtml$1(section.title)}</span>
|
|
1415
|
-
</div>
|
|
1416
|
-
<div class="pr-helper-guide-meta-row" title="Review weight ${section.reviewWeight}/5">${escapeHtml$1(statParts.join(" · "))}</div>
|
|
1417
|
-
</button>
|
|
1558
|
+
${renderSectionHeader(section, `${check}${statusBadge}`, statParts, collapsible)}
|
|
1418
1559
|
<div class="pr-helper-guide-section-body">
|
|
1419
1560
|
<p class="pr-helper-guide-summary">${renderInlineText(section.oneLineSummary)}</p>
|
|
1420
1561
|
${section.purpose ? `<p class="pr-helper-guide-purpose">${renderInlineText(section.purpose)}</p>` : ""}
|
|
1421
|
-
<h5>Files</h5>
|
|
1422
|
-
<ul class="pr-helper-guide-files">${files}</ul>
|
|
1423
1562
|
${section.whyReview ? `<h5>Why review this</h5><p class="pr-helper-guide-why">${renderInlineText(section.whyReview)}</p>` : ""}
|
|
1424
1563
|
<h5>Review checklist</h5>
|
|
1425
1564
|
<ul class="pr-helper-guide-checklist">${checklist}</ul>
|
|
1565
|
+
<h5>Files</h5>
|
|
1566
|
+
<ul class="pr-helper-guide-files">${files}</ul>
|
|
1426
1567
|
<div class="pr-helper-guide-section-actions">
|
|
1427
1568
|
<button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml$1(section.id)}">
|
|
1428
1569
|
${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
|
|
@@ -1898,6 +2039,90 @@ function renderQuestionsTab(session, investigatingId, options) {
|
|
|
1898
2039
|
`).join("")}${ungrouped.map(card).join("")}${closed}`;
|
|
1899
2040
|
}
|
|
1900
2041
|
//#endregion
|
|
2042
|
+
//#region ../../packages/review-harness/src/workspace/guide_view.ts
|
|
2043
|
+
/**
|
|
2044
|
+
* The workspace's Guide: one theme at a time.
|
|
2045
|
+
*
|
|
2046
|
+
* The extension renders the Guide as a stack of collapsible cards because a 460px drawer beside a
|
|
2047
|
+
* diff has no room for anything else. The workspace has an 860px pane and a rail that already
|
|
2048
|
+
* lists every theme as a selectable row, so stacking them here bought a long scroll and nothing
|
|
2049
|
+
* else: reading the second theme meant scrolling past the whole of the first.
|
|
2050
|
+
*
|
|
2051
|
+
* So this is a composition, not a second Guide. The theme body is still
|
|
2052
|
+
* `renderGuideSectionCard`, and the extension's `renderGuideTab` is untouched. What changes is
|
|
2053
|
+
* which themes are on screen and what sits around them.
|
|
2054
|
+
*
|
|
2055
|
+
* Every theme is rendered and all but one carries `hidden`, the same way `.prb-view` works on this
|
|
2056
|
+
* page: switching a theme is then a class change rather than a request, and the reviewer's place
|
|
2057
|
+
* in a long checklist survives it.
|
|
2058
|
+
*/
|
|
2059
|
+
/**
|
|
2060
|
+
* What the workspace asks of a theme card: a diagram button, never a folded card, and no control
|
|
2061
|
+
* to fold it. Collapsing made sense when a theme was one of several stacked in a column and
|
|
2062
|
+
* folding it brought the next into view. Here it would leave a title over an empty pane.
|
|
2063
|
+
*/
|
|
2064
|
+
var CARD_OPTIONS = {
|
|
2065
|
+
diagrams: true,
|
|
2066
|
+
collapsible: false
|
|
2067
|
+
};
|
|
2068
|
+
/** What the shared renderer is asked for when this view has nothing of its own to draw. */
|
|
2069
|
+
var DELEGATED_OPTIONS = {
|
|
2070
|
+
diagrams: true,
|
|
2071
|
+
collapsedSections: true
|
|
2072
|
+
};
|
|
2073
|
+
function renderWorkspaceGuide(session, followUpInFlight = null) {
|
|
2074
|
+
const outcome = outcomeOf(session);
|
|
2075
|
+
if (!session || outcome.kind !== "empty" && outcome.kind !== "findings") return renderGuideTab(session, null, followUpInFlight, DELEGATED_OPTIONS);
|
|
2076
|
+
const result = outcome.review;
|
|
2077
|
+
const sections = result.sections;
|
|
2078
|
+
if (sections.length === 0) return renderGuideTab(session, null, followUpInFlight, DELEGATED_OPTIONS);
|
|
2079
|
+
const completed = new Set(session.completedSectionIds ?? []);
|
|
2080
|
+
const statuses = deriveSectionStatuses(sections, completed);
|
|
2081
|
+
const activeBadgeLabel = [...statuses.values()].includes("completed") ? "Continue" : "Start here";
|
|
2082
|
+
const selected = nextRecommendedSection(sections, completed) ?? sections[0];
|
|
2083
|
+
const details = sections.map((section) => {
|
|
2084
|
+
const card = renderGuideSectionCard(section, {
|
|
2085
|
+
issues: result.issues.filter((issue) => issue.sectionId === section.id).length,
|
|
2086
|
+
questions: result.questions.filter((question) => question.sectionId === section.id).length
|
|
2087
|
+
}, null, statuses.get(section.id) ?? "pending", activeBadgeLabel, CARD_OPTIONS);
|
|
2088
|
+
const hidden = section.id === selected.id ? "" : " hidden";
|
|
2089
|
+
return `<div class="prb-guide-detail" data-guide-detail="${escapeHtml$1(section.id)}"${hidden}>${card}</div>`;
|
|
2090
|
+
}).join("");
|
|
2091
|
+
return `
|
|
2092
|
+
<div class="pr-helper-guide prb-guide">
|
|
2093
|
+
${renderGuideProgress(sections, completed)}
|
|
2094
|
+
${renderThemePicker(sections, selected.id)}
|
|
2095
|
+
<div class="prb-guide-details">${details}</div>
|
|
2096
|
+
<div class="prb-guide-foot">${renderSuggestedAreas(session, followUpInFlight)}</div>
|
|
2097
|
+
</div>
|
|
2098
|
+
`;
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* One line, because the rail carries the rest.
|
|
2102
|
+
*
|
|
2103
|
+
* The shared Guide opens with a suggested-path box listing every theme in order. Here the rail is
|
|
2104
|
+
* that list, and repeating it above the theme would be the same sentence twice. What the rail does
|
|
2105
|
+
* not carry is time, so that is what this adds to the count.
|
|
2106
|
+
*/
|
|
2107
|
+
function renderGuideProgress(sections, completed) {
|
|
2108
|
+
const total = sections.length;
|
|
2109
|
+
const done = sections.filter((section) => completed.has(section.id)).length;
|
|
2110
|
+
const left = remainingMinutes(sections, completed);
|
|
2111
|
+
const time = left > 0 ? ` · ~${left} min` : "";
|
|
2112
|
+
return `<p class="prb-guide-progress">${done > 0 ? `${done} of ${total} theme${total === 1 ? "" : "s"} completed` : `${total} theme${total === 1 ? "" : "s"}`}${time}</p>`;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* The same theme list the rail draws, for the width where the rail is not drawn.
|
|
2116
|
+
*
|
|
2117
|
+
* Below 860px the rail is hidden, and a Guide whose only theme navigation lives there would have
|
|
2118
|
+
* none at all. This is not a second navigation model: it is the same sections, reusing
|
|
2119
|
+
* `data-goto-section` so the existing handler drives it, and CSS shows it only where the rail is
|
|
2120
|
+
* absent. The workspace places in the narrow header follow exactly this pattern.
|
|
2121
|
+
*/
|
|
2122
|
+
function renderThemePicker(sections, selectedId) {
|
|
2123
|
+
return `<div class="prb-guide-picker">${sections.map((section, index) => `<button type="button" class="prb-guide-pick" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}" aria-current="${section.id === selectedId}"><span class="prb-guide-pick-order">${index + 1}</span>${escapeHtml$1(section.title)}</button>`).join("")}</div>`;
|
|
2124
|
+
}
|
|
2125
|
+
//#endregion
|
|
1901
2126
|
//#region ../../packages/review-harness/src/workspace/doc_head.ts
|
|
1902
2127
|
/**
|
|
1903
2128
|
* The title row a document pane opens with, and the provenance disclosure at the right of it.
|
|
@@ -3396,7 +3621,7 @@ function renderWorkspace(view) {
|
|
|
3396
3621
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
3397
3622
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
3398
3623
|
</head>
|
|
3399
|
-
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
|
|
3624
|
+
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
|
|
3400
3625
|
${renderHeader(view, freshness)}
|
|
3401
3626
|
${showsProgress(view) ? "" : renderJobState(view.job ?? null)}
|
|
3402
3627
|
<div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.availability ? ` data-availability="${escapeHtml$1(JSON.stringify(view.availability))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
|
|
@@ -3534,10 +3759,7 @@ function renderStandardViews(view, sections) {
|
|
|
3534
3759
|
diagrams: true,
|
|
3535
3760
|
detailsInHeader: true
|
|
3536
3761
|
})}</section>
|
|
3537
|
-
<section class="prb-view" data-view="guide" hidden>${
|
|
3538
|
-
diagrams: true,
|
|
3539
|
-
collapsedSections: true
|
|
3540
|
-
})}</section>
|
|
3762
|
+
<section class="prb-view" data-view="guide" hidden>${renderWorkspaceGuide(session)}</section>
|
|
3541
3763
|
<section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
|
|
3542
3764
|
<h3 class="prb-doc-title">Diagrams</h3>
|
|
3543
3765
|
${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
|
|
@@ -3782,24 +4004,11 @@ function renderIdMenu(id, canDelete) {
|
|
|
3782
4004
|
*/
|
|
3783
4005
|
function renderRailFoot(view) {
|
|
3784
4006
|
return `<div class="prb-rail-group prb-rail-foot">
|
|
3785
|
-
${view.token ?
|
|
3786
|
-
|
|
3787
|
-
<span class="prb-rail-label"><span class="prb-rail-title">All reviews</span></span>
|
|
3788
|
-
</a>
|
|
3789
|
-
<a class="prb-rail-item prb-rail-link" href="/settings">
|
|
3790
|
-
${railIcon("settings")}
|
|
3791
|
-
<span class="prb-rail-label"><span class="prb-rail-title">Settings</span></span>
|
|
3792
|
-
</a>` : ""}
|
|
3793
|
-
<a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
|
|
3794
|
-
${railIcon("feedback")}
|
|
3795
|
-
<span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
|
|
3796
|
-
</a>
|
|
4007
|
+
${view.token ? renderWorkspaceNav({ unread: view.unreadNotifications ?? 0 }) : ""}
|
|
4008
|
+
${renderSendFeedback()}
|
|
3797
4009
|
</div>
|
|
3798
4010
|
<div class="prb-rail-group prb-rail-ops">
|
|
3799
|
-
${view.token ?
|
|
3800
|
-
${railIcon("stop")}
|
|
3801
|
-
<span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
|
|
3802
|
-
</button>` : ""}
|
|
4011
|
+
${view.token ? renderStopServer() : ""}
|
|
3803
4012
|
<p class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</p>
|
|
3804
4013
|
</div>`;
|
|
3805
4014
|
}
|
|
@@ -3834,14 +4043,14 @@ function renderRail(view, sections) {
|
|
|
3834
4043
|
const dispositions = reviewerDispositions(view.session);
|
|
3835
4044
|
const themes = [...sections].sort((a, b) => a.order - b.order).map((section, index) => {
|
|
3836
4045
|
const sectionIssues = issuesOutstanding(issues.filter((issue) => issue.sectionId === section.id), dispositions);
|
|
3837
|
-
const
|
|
4046
|
+
const detail = [`~${themeMinutes(section)} min`, themeFindingsNote(sectionIssues)].filter(Boolean).join(" · ");
|
|
4047
|
+
const note = `<span class="prb-rail-meta">${section.recommendedStartingPoint ? "<span class=\"prb-rail-start\">Start here</span>" : ""}<span class="prb-rail-note">${detail}</span></span>`;
|
|
3838
4048
|
return `<button type="button" class="prb-rail-item" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}">
|
|
3839
4049
|
<span class="prb-rail-order">${index + 1}</span>
|
|
3840
4050
|
<span class="prb-rail-label">
|
|
3841
4051
|
<span class="prb-rail-title">${escapeHtml$1(section.title)}</span>
|
|
3842
4052
|
${note}
|
|
3843
4053
|
</span>
|
|
3844
|
-
${renderBadge(themeBadge(sectionIssues))}
|
|
3845
4054
|
<span class="prb-attention" data-attention="${escapeHtml$1(section.reviewAttention)}" title="Needs ${escapeHtml$1(section.reviewAttention)} attention"></span>
|
|
3846
4055
|
</button>`;
|
|
3847
4056
|
}).join("");
|
|
@@ -3854,10 +4063,10 @@ function renderRail(view, sections) {
|
|
|
3854
4063
|
current: true,
|
|
3855
4064
|
badge: null,
|
|
3856
4065
|
collapses: "the review",
|
|
3857
|
-
body: `<
|
|
3858
|
-
<
|
|
3859
|
-
<
|
|
3860
|
-
</
|
|
4066
|
+
body: `<button type="button" class="prb-rail-subhead" data-rail-toggle="the review path" aria-expanded="true" aria-controls="prb-rail-path" aria-label="Collapse the review path">
|
|
4067
|
+
<span class="prb-rail-heading">Review path</span>
|
|
4068
|
+
<span class="prb-rail-twisty" aria-hidden="true">${TWISTY}</span>
|
|
4069
|
+
</button>
|
|
3861
4070
|
<div class="prb-rail-path" id="prb-rail-path">
|
|
3862
4071
|
${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
|
|
3863
4072
|
</div>
|
|
@@ -3986,24 +4195,27 @@ function totalBadge(count, noun) {
|
|
|
3986
4195
|
* A zero total keeps its `0`: "we looked and found none" is a different fact from "you have dealt
|
|
3987
4196
|
* with all of them", and a tick would claim the second on a review that earned the first.
|
|
3988
4197
|
*/
|
|
4198
|
+
/** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
|
|
4199
|
+
function plural(n, noun) {
|
|
4200
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
4201
|
+
}
|
|
3989
4202
|
function remainingBadge(counts, noun, verb) {
|
|
3990
|
-
const plural = (n) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
3991
4203
|
if (counts.total === 0) return {
|
|
3992
4204
|
text: "0",
|
|
3993
4205
|
label: `No ${noun}s`
|
|
3994
4206
|
};
|
|
3995
4207
|
if (counts.open === 0) return {
|
|
3996
4208
|
text: "✓",
|
|
3997
|
-
label: `All ${plural(counts.total)} ${verb}`,
|
|
4209
|
+
label: `All ${plural(counts.total, noun)} ${verb}`,
|
|
3998
4210
|
done: true
|
|
3999
4211
|
};
|
|
4000
4212
|
if (counts.done === 0) return {
|
|
4001
4213
|
text: String(counts.open),
|
|
4002
|
-
label: plural(counts.total)
|
|
4214
|
+
label: plural(counts.total, noun)
|
|
4003
4215
|
};
|
|
4004
4216
|
return {
|
|
4005
4217
|
text: String(counts.open),
|
|
4006
|
-
label: `${counts.open} of ${plural(counts.total)} left, ${counts.done} ${verb}`
|
|
4218
|
+
label: `${counts.open} of ${plural(counts.total, noun)} left, ${counts.done} ${verb}`
|
|
4007
4219
|
};
|
|
4008
4220
|
}
|
|
4009
4221
|
/**
|
|
@@ -4016,10 +4228,20 @@ function remainingBadge(counts, noun, verb) {
|
|
|
4016
4228
|
function findingsBadge(counts) {
|
|
4017
4229
|
return remainingBadge(counts, "issue", doneVerb(counts));
|
|
4018
4230
|
}
|
|
4019
|
-
/**
|
|
4020
|
-
|
|
4231
|
+
/**
|
|
4232
|
+
* What a theme's row says about its findings, in words rather than as a bare numeral.
|
|
4233
|
+
*
|
|
4234
|
+
* This was a badge: the digit "1" at the end of the row, beside an equally unlabelled attention
|
|
4235
|
+
* dot, with the sentence that explained it reaching only a screen reader through `aria-label`. Two
|
|
4236
|
+
* unlabelled marks on one row is worse than one, and the reader looking at it got strictly less
|
|
4237
|
+
* than the reader hearing it. So the count joins the note already on the row, which already reads
|
|
4238
|
+
* in words. Silent when the theme never had a finding, so most rows stay bare.
|
|
4239
|
+
*/
|
|
4240
|
+
function themeFindingsNote(counts) {
|
|
4021
4241
|
if (counts.total === 0) return null;
|
|
4022
|
-
return
|
|
4242
|
+
if (counts.open === 0) return `all ${plural(counts.total, "issue")} ${doneVerb(counts)}`;
|
|
4243
|
+
if (counts.done === 0) return plural(counts.open, "issue");
|
|
4244
|
+
return `${counts.open} of ${plural(counts.total, "issue")} left`;
|
|
4023
4245
|
}
|
|
4024
4246
|
/**
|
|
4025
4247
|
* `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
|
|
@@ -4258,17 +4480,6 @@ function renderPromptDialog() {
|
|
|
4258
4480
|
* could fire after the server had already stopped and reset the label to an offer that no longer
|
|
4259
4481
|
* meant anything. A dialog has no timer to race, and it has room to say what is about to happen.
|
|
4260
4482
|
*/
|
|
4261
|
-
function renderShutdownDialog() {
|
|
4262
|
-
return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
|
|
4263
|
-
<h2 id="prb-shutdown-title">Stop the workspace server?</h2>
|
|
4264
|
-
<p>One server serves every review on this machine, so <b>every open workspace stops</b>, not just this one.</p>
|
|
4265
|
-
<p class="prb-dialog-reassure">Nothing is deleted. Your reviews stay on disk for thirty days, and their links keep working. Run <code>${escapeHtml$1(wayBackSentence())}</code> to start the server again.</p>
|
|
4266
|
-
<div class="prb-dialog-actions">
|
|
4267
|
-
<button type="button" data-shutdown-cancel>Cancel</button>
|
|
4268
|
-
<button type="button" class="prb-dialog-danger" data-shutdown-confirm>Stop server</button>
|
|
4269
|
-
</div>
|
|
4270
|
-
</dialog>`;
|
|
4271
|
-
}
|
|
4272
4483
|
function renderAsk(view, sections) {
|
|
4273
4484
|
const suggestions = buildSuggestions(view, sections);
|
|
4274
4485
|
const transcript = view.transcript ?? [];
|
|
@@ -4438,16 +4649,23 @@ function renderReviewsPage(view) {
|
|
|
4438
4649
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
4439
4650
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
4440
4651
|
</head>
|
|
4441
|
-
<body class="prb-index-body"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
|
|
4652
|
+
<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())}">
|
|
4442
4653
|
<header class="prb-header">
|
|
4443
4654
|
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
4444
|
-
<span class="prb-
|
|
4655
|
+
<span class="prb-page-title">Reviews</span>
|
|
4445
4656
|
<div class="prb-header-stats">
|
|
4657
|
+
${renderHeaderNav({
|
|
4658
|
+
current: "reviews",
|
|
4659
|
+
unread: view.unreadNotifications ?? 0
|
|
4660
|
+
})}
|
|
4446
4661
|
<span class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</span>
|
|
4447
|
-
<a class="prb-feedback" href="/settings">Settings</a>
|
|
4448
|
-
<a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a>
|
|
4449
4662
|
</div>
|
|
4450
4663
|
</header>
|
|
4664
|
+
<div class="prb-shell">
|
|
4665
|
+
${renderWorkspaceRail({
|
|
4666
|
+
current: "reviews",
|
|
4667
|
+
unread: view.unreadNotifications ?? 0
|
|
4668
|
+
})}
|
|
4451
4669
|
<main class="prb-index">
|
|
4452
4670
|
${ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle)))}
|
|
4453
4671
|
${front === void 0 ? "" : renderContinue(front)}
|
|
@@ -4463,6 +4681,8 @@ function renderReviewsPage(view) {
|
|
|
4463
4681
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
4464
4682
|
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
|
|
4465
4683
|
</main>
|
|
4684
|
+
</div>
|
|
4685
|
+
${renderShutdownDialog()}
|
|
4466
4686
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
4467
4687
|
</body>
|
|
4468
4688
|
</html>`;
|
|
@@ -5013,15 +5233,23 @@ function renderSettingsPage(view) {
|
|
|
5013
5233
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
5014
5234
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
5015
5235
|
</head>
|
|
5016
|
-
<body class="prb-index-body">
|
|
5236
|
+
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${Date.now()}">
|
|
5017
5237
|
<header class="prb-header">
|
|
5018
5238
|
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
5019
|
-
<span class="prb-
|
|
5239
|
+
<span class="prb-page-title">Settings</span>
|
|
5020
5240
|
<div class="prb-header-stats">
|
|
5241
|
+
${renderHeaderNav({
|
|
5242
|
+
current: "settings",
|
|
5243
|
+
unread: view.unreadNotifications ?? 0
|
|
5244
|
+
})}
|
|
5021
5245
|
<span class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</span>
|
|
5022
|
-
<a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a>
|
|
5023
5246
|
</div>
|
|
5024
5247
|
</header>
|
|
5248
|
+
<div class="prb-shell">
|
|
5249
|
+
${renderWorkspaceRail({
|
|
5250
|
+
current: "settings",
|
|
5251
|
+
unread: view.unreadNotifications ?? 0
|
|
5252
|
+
})}
|
|
5025
5253
|
<main class="prb-index prb-settings">
|
|
5026
5254
|
${renderBack()}
|
|
5027
5255
|
${renderWorkspaceSection(settings, address)}
|
|
@@ -5037,6 +5265,8 @@ function renderSettingsPage(view) {
|
|
|
5037
5265
|
edit by hand, and they are the same settings <code>prreviewbuddy config</code> reads and writes.
|
|
5038
5266
|
Run <code>prreviewbuddy config path</code> to be told where that file is.</p>
|
|
5039
5267
|
</main>
|
|
5268
|
+
</div>
|
|
5269
|
+
${renderShutdownDialog()}
|
|
5040
5270
|
<script src="/assets/settings.js" type="module"><\/script>
|
|
5041
5271
|
</body>
|
|
5042
5272
|
</html>`;
|
|
@@ -5121,6 +5351,77 @@ function renderPrivacySection(settings) {
|
|
|
5121
5351
|
</section>`;
|
|
5122
5352
|
}
|
|
5123
5353
|
//#endregion
|
|
5354
|
+
//#region ../../packages/review-harness/src/workspace/notifications_page.ts
|
|
5355
|
+
function renderNotificationsPage(view) {
|
|
5356
|
+
const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
|
|
5357
|
+
return `<!doctype html>
|
|
5358
|
+
<html lang="en">
|
|
5359
|
+
<head>
|
|
5360
|
+
<meta charset="utf-8">
|
|
5361
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5362
|
+
<title>Notifications · PR Review Buddy</title>
|
|
5363
|
+
<link rel="icon" type="image/png" href="/assets/icon128.png">
|
|
5364
|
+
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
5365
|
+
<link rel="stylesheet" href="/assets/shell.css">
|
|
5366
|
+
</head>
|
|
5367
|
+
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}">
|
|
5368
|
+
<header class="prb-header">
|
|
5369
|
+
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
5370
|
+
<span class="prb-page-title">Notifications</span>
|
|
5371
|
+
<div class="prb-header-stats">
|
|
5372
|
+
${renderHeaderNav({
|
|
5373
|
+
current: "notifications",
|
|
5374
|
+
unread: view.unreadNotifications ?? 0
|
|
5375
|
+
})}
|
|
5376
|
+
<span class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</span>
|
|
5377
|
+
</div>
|
|
5378
|
+
</header>
|
|
5379
|
+
<div class="prb-shell">
|
|
5380
|
+
${renderWorkspaceRail({
|
|
5381
|
+
current: "notifications",
|
|
5382
|
+
unread: view.unreadNotifications ?? 0
|
|
5383
|
+
})}
|
|
5384
|
+
<main class="prb-index prb-notifications-page">
|
|
5385
|
+
${view.notifications.length === 0 ? `<p class="prb-index-empty">Nothing yet. When a review finishes or fails, it appears here.</p>` : `<div class="prb-index-summary">
|
|
5386
|
+
<p class="prb-index-count" data-notifications-count role="status" aria-live="polite">${escapeHtml$1(unreadLabel(unread))}</p>
|
|
5387
|
+
<button type="button" class="prb-index-select" data-notifications-read-all${unread === 0 ? " disabled" : ""}>Mark all read</button>
|
|
5388
|
+
</div>
|
|
5389
|
+
<ol class="prb-notifications" data-notifications>
|
|
5390
|
+
${view.notifications.map((notification) => renderEntry(notification, view.gone?.has(notification.id) === true)).join("")}
|
|
5391
|
+
</ol>`}
|
|
5392
|
+
<p class="prb-index-note">Kept for thirty days, on this machine only.</p>
|
|
5393
|
+
</main>
|
|
5394
|
+
</div>
|
|
5395
|
+
${renderShutdownDialog()}
|
|
5396
|
+
<script src="/assets/notifications.js" type="module"><\/script>
|
|
5397
|
+
</body>
|
|
5398
|
+
</html>`;
|
|
5399
|
+
}
|
|
5400
|
+
/**
|
|
5401
|
+
* One event: what happened, then what to, then when. The whole row opens the review and so marks it
|
|
5402
|
+
* read, which is why a row has no Mark read of its own. An event, not a card: nothing about the
|
|
5403
|
+
* review's findings belongs here.
|
|
5404
|
+
*/
|
|
5405
|
+
function renderEntry(notification, gone) {
|
|
5406
|
+
const read = notification.readAt !== void 0;
|
|
5407
|
+
const event = eventName(notification);
|
|
5408
|
+
const after = [
|
|
5409
|
+
notification.branch,
|
|
5410
|
+
notification.repo,
|
|
5411
|
+
relativeTime$1(notification.createdAt)
|
|
5412
|
+
].filter((part) => Boolean(part));
|
|
5413
|
+
const note = gone ? " · this review has been deleted" : "";
|
|
5414
|
+
const spoken = `${read ? "" : "Unread. "}${event}, ${[notification.subject, ...after].join(", ")}${gone ? ", this review has been deleted" : ""}`;
|
|
5415
|
+
const body = `<span class="prb-notification-dot" aria-hidden="true"></span>
|
|
5416
|
+
<span class="prb-notification-text">
|
|
5417
|
+
<span class="prb-notification-event" data-outcome="${notification.outcome}">${escapeHtml$1(event)}</span>
|
|
5418
|
+
<span class="prb-notification-meta"><span class="prb-notification-subject">${escapeHtml$1(notification.subject)}</span>${after.map((part) => ` · ${escapeHtml$1(part)}`).join("")}${note}</span>
|
|
5419
|
+
</span>`;
|
|
5420
|
+
return `<li class="prb-notification" data-notification-id="${escapeHtml$1(notification.id)}" data-read="${read}">
|
|
5421
|
+
${gone ? `<span class="prb-notification-open" aria-label="${escapeHtml$1(spoken)}">${body}</span>` : `<a class="prb-notification-open" href="/notifications/open?id=${encodeURIComponent(notification.id)}" aria-label="${escapeHtml$1(spoken)}">${body}</a>`}
|
|
5422
|
+
</li>`;
|
|
5423
|
+
}
|
|
5424
|
+
//#endregion
|
|
5124
5425
|
//#region ../../packages/review-harness/src/workspace/reviews_revision.ts
|
|
5125
5426
|
/**
|
|
5126
5427
|
* Whether anything the reviews index draws has changed, as one token.
|
|
@@ -6072,11 +6373,15 @@ async function handle(req, res, options) {
|
|
|
6072
6373
|
if (url.pathname === "/") return handleIndex(req, res, url.searchParams, options.indexToken);
|
|
6073
6374
|
if (url.pathname === "/open") return handleOpen(req, res, url.searchParams, options.indexToken);
|
|
6074
6375
|
if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken);
|
|
6376
|
+
if (url.pathname === "/notifications") return void handleNotifications(req, res, url.searchParams, options.indexToken);
|
|
6377
|
+
if (url.pathname === "/notifications/open") return handleNotificationOpen(req, res, url.searchParams, options.indexToken);
|
|
6378
|
+
if (url.pathname === "/notifications/recent") return handleRecentNotifications(req, res, url.searchParams, options.indexToken);
|
|
6075
6379
|
if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
|
|
6076
6380
|
if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
|
|
6077
6381
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
6078
6382
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
6079
6383
|
if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
|
|
6384
|
+
if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
|
|
6080
6385
|
const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
|
|
6081
6386
|
if (!match) return send(res, 404, { error: "Not found" });
|
|
6082
6387
|
const [, id, action] = match;
|
|
@@ -6171,7 +6476,8 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
6171
6476
|
groups,
|
|
6172
6477
|
queue: queue.filter((id) => rendered.has(id)),
|
|
6173
6478
|
analyses: states,
|
|
6174
|
-
revision: reviewsRevision(states)
|
|
6479
|
+
revision: reviewsRevision(states),
|
|
6480
|
+
unreadNotifications: unreadCount$1()
|
|
6175
6481
|
}), cookie);
|
|
6176
6482
|
}
|
|
6177
6483
|
/**
|
|
@@ -6256,7 +6562,10 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
6256
6562
|
error
|
|
6257
6563
|
});
|
|
6258
6564
|
}
|
|
6259
|
-
sendHtml(res, 200, renderSettingsPage({
|
|
6565
|
+
sendHtml(res, 200, renderSettingsPage({
|
|
6566
|
+
settings: readSettings(),
|
|
6567
|
+
unreadNotifications: unreadCount$1()
|
|
6568
|
+
}), indexCookieHeader(indexToken));
|
|
6260
6569
|
}
|
|
6261
6570
|
/**
|
|
6262
6571
|
* Whether the caller is a browser navigating, rather than something reading the API.
|
|
@@ -6290,6 +6599,73 @@ function handleOpen(req, res, params, indexToken) {
|
|
|
6290
6599
|
return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}`);
|
|
6291
6600
|
}
|
|
6292
6601
|
/**
|
|
6602
|
+
* What an open workspace page should show as toasts: unread notifications recorded since it last
|
|
6603
|
+
* asked, and how many more there were than it has room to show.
|
|
6604
|
+
*
|
|
6605
|
+
* The cookie only, like `/revision`, because the caller is a page's script rather than a navigation.
|
|
6606
|
+
* `now` is read before the list, and nothing newer than it is returned, so an event recorded between
|
|
6607
|
+
* the two reads is handed over on the next poll rather than on both. What goes back is the line and
|
|
6608
|
+
* the id; the id opens nothing except through `/notifications/open`, which asks for the credential
|
|
6609
|
+
* again.
|
|
6610
|
+
*/
|
|
6611
|
+
function handleRecentNotifications(req, res, params, indexToken) {
|
|
6612
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
6613
|
+
const now = Date.now();
|
|
6614
|
+
const after = Number(params.get("after"));
|
|
6615
|
+
const { shown, more } = splitForToasts(Number.isFinite(after) ? listNotifications().filter((entry) => entry.readAt === void 0 && entry.createdAt > after && entry.createdAt <= now) : []);
|
|
6616
|
+
send(res, 200, {
|
|
6617
|
+
now,
|
|
6618
|
+
notifications: shown.map((entry) => ({
|
|
6619
|
+
id: entry.id,
|
|
6620
|
+
headline: headline(entry),
|
|
6621
|
+
outcome: entry.outcome
|
|
6622
|
+
})),
|
|
6623
|
+
more
|
|
6624
|
+
});
|
|
6625
|
+
}
|
|
6626
|
+
/**
|
|
6627
|
+
* The Notifications page, and marking its entries read.
|
|
6628
|
+
*
|
|
6629
|
+
* The index's authority, like Settings: the list names every review on the machine, which is the
|
|
6630
|
+
* machine's user's business and no single review's. `GET` renders and `POST` marks, answering with
|
|
6631
|
+
* the unread count as it now stands so the page paints the files rather than its own arithmetic.
|
|
6632
|
+
*/
|
|
6633
|
+
async function handleNotifications(req, res, params, indexToken) {
|
|
6634
|
+
if (!indexAuthorised(req, params, indexToken)) {
|
|
6635
|
+
if (req.method === "POST") return send(res, 403, { error: "This link is missing its access token." });
|
|
6636
|
+
return refuseIndex(req, res);
|
|
6637
|
+
}
|
|
6638
|
+
if (req.method === "POST") {
|
|
6639
|
+
const body = await readJson(req);
|
|
6640
|
+
if (body?.action === "read-all") markAllRead();
|
|
6641
|
+
else if (body?.action === "read" && Array.isArray(body.ids)) markRead(body.ids.filter((id) => typeof id === "string"));
|
|
6642
|
+
else return send(res, 400, { error: "Say what to mark: `read` with the ids, or `read-all`." });
|
|
6643
|
+
return send(res, 200, { unread: unreadCount$1() });
|
|
6644
|
+
}
|
|
6645
|
+
const notifications = listNotifications();
|
|
6646
|
+
sendHtml(res, 200, renderNotificationsPage({
|
|
6647
|
+
notifications,
|
|
6648
|
+
gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
|
|
6649
|
+
unreadNotifications: unreadCount$1()
|
|
6650
|
+
}), indexCookieHeader(indexToken));
|
|
6651
|
+
}
|
|
6652
|
+
/**
|
|
6653
|
+
* Open one notification: mark it read, then go to its review with the review's own token.
|
|
6654
|
+
*
|
|
6655
|
+
* The one way a notification is read by being followed. Arriving at the same review from anywhere
|
|
6656
|
+
* else leaves it unread, because that is not the reviewer having seen this. The redirect is the one
|
|
6657
|
+
* `handleOpen` makes, so no second kind of link to a review exists.
|
|
6658
|
+
*/
|
|
6659
|
+
function handleNotificationOpen(req, res, params, indexToken) {
|
|
6660
|
+
if (!indexAuthorised(req, params, indexToken)) return refuseIndex(req, res);
|
|
6661
|
+
const notification = readNotification(params.get("id") ?? "");
|
|
6662
|
+
if (!notification) return redirect(res, "/notifications");
|
|
6663
|
+
markRead([notification.id]);
|
|
6664
|
+
const workspace = loadWorkspace(notification.reviewId);
|
|
6665
|
+
if (!workspace) return redirect(res, "/notifications");
|
|
6666
|
+
return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}`);
|
|
6667
|
+
}
|
|
6668
|
+
/**
|
|
6293
6669
|
* Mark a selection reviewed, or put it back to in progress.
|
|
6294
6670
|
*
|
|
6295
6671
|
* Authorised by the index credential, like pinning and deleting, because this is the list acting on
|
|
@@ -6355,7 +6731,8 @@ function handleRevision(req, res, indexToken) {
|
|
|
6355
6731
|
const states = analysisStates();
|
|
6356
6732
|
send(res, 200, {
|
|
6357
6733
|
revision: reviewsRevision(states),
|
|
6358
|
-
live: anythingRunning(states)
|
|
6734
|
+
live: anythingRunning(states),
|
|
6735
|
+
unread: unreadCount$1()
|
|
6359
6736
|
});
|
|
6360
6737
|
}
|
|
6361
6738
|
/**
|
|
@@ -6631,6 +7008,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
6631
7008
|
pendingDiagram: reportRun(workspace.id, "diagram"),
|
|
6632
7009
|
prompts: readPromptBank(),
|
|
6633
7010
|
complete: workspace.completedAt !== void 0,
|
|
7011
|
+
unreadNotifications: unreadCount$1(),
|
|
6634
7012
|
prContext: workspace.prContext ? {
|
|
6635
7013
|
prContext: workspace.prContext,
|
|
6636
7014
|
processed: processDiscussion(workspace.prContext.discussion)
|
|
@@ -7097,6 +7475,16 @@ async function handleDiagram(req, res, workspace) {
|
|
|
7097
7475
|
*
|
|
7098
7476
|
* One server serves every review, so this ends all of them. The page says so before asking.
|
|
7099
7477
|
*/
|
|
7478
|
+
/**
|
|
7479
|
+
* Stop server from the workspace pages that are not a review: the reviews list, Notifications and
|
|
7480
|
+
* Settings. They hold no review token, so the index cookie authorises it, exactly as the list's own
|
|
7481
|
+
* actions are. POST only, so a link or a prefetch can never end every open review.
|
|
7482
|
+
*/
|
|
7483
|
+
function handleIndexShutdown(req, res, options) {
|
|
7484
|
+
if (!options.indexToken || !secretMatches(options.indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
7485
|
+
if (req.method !== "POST") return send(res, 405, { error: "Stopping the server is a POST." });
|
|
7486
|
+
handleShutdown(res, options);
|
|
7487
|
+
}
|
|
7100
7488
|
function handleShutdown(res, options) {
|
|
7101
7489
|
send(res, 200, { ok: true });
|
|
7102
7490
|
const stop = options.onShutdown ?? (() => process.exit(0));
|
|
@@ -7304,6 +7692,7 @@ async function sweep(now = Date.now()) {
|
|
|
7304
7692
|
if (await retire(job, report)) report.retired += 1;
|
|
7305
7693
|
}
|
|
7306
7694
|
await removeOrphans(jobs, report);
|
|
7695
|
+
pruneNotifications(now);
|
|
7307
7696
|
return report;
|
|
7308
7697
|
}
|
|
7309
7698
|
/**
|
|
@@ -7318,7 +7707,9 @@ function markAbandoned(job, now) {
|
|
|
7318
7707
|
if (isClaimed(job.id, now)) return false;
|
|
7319
7708
|
const current = loadJob(job.id);
|
|
7320
7709
|
if (!current || current.updatedAt !== job.updatedAt) return false;
|
|
7321
|
-
|
|
7710
|
+
const abandoned = fail(current, ABANDONED_MESSAGE);
|
|
7711
|
+
saveJob(abandoned);
|
|
7712
|
+
announceJob(abandoned);
|
|
7322
7713
|
return true;
|
|
7323
7714
|
}
|
|
7324
7715
|
/** A finished job, discarded on the shared sequence. See `job_cleanup.ts` for why it is an order. */
|