@khanglvm/relay 0.13.8 → 0.14.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/README.md +10 -7
- package/docs/AGENT.md +23 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +26 -3
- package/src/cli.js +96 -9
- package/src/mcp.js +4 -3
- package/src/server.js +262 -22
- package/src/spec.js +8 -1
- package/src/ui/annotate.js +35 -7
- package/src/ui/app.js +152 -4
- package/src/ui/blocks.css +62 -0
- package/src/ui/blocks.js +151 -0
- package/src/ui/style.css +52 -1
package/src/ui/app.js
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
const boot = JSON.parse(document.getElementById('boot').textContent);
|
|
5
5
|
const spec = boot.spec;
|
|
6
|
+
const access = boot.access || {
|
|
7
|
+
role: 'owner',
|
|
8
|
+
canShare: true,
|
|
9
|
+
canSubmit: true,
|
|
10
|
+
canEditAnswers: true,
|
|
11
|
+
canComment: true,
|
|
12
|
+
canEditComments: true,
|
|
13
|
+
canDeleteComments: true,
|
|
14
|
+
};
|
|
15
|
+
if (access.role) document.documentElement.dataset.accessRole = access.role;
|
|
6
16
|
const QS = spec.questions || [];
|
|
7
17
|
const app = document.getElementById('app');
|
|
8
18
|
const banner = document.getElementById('banner');
|
|
@@ -10,6 +20,11 @@
|
|
|
10
20
|
// compares /api/status.rev to this and reloads the board when it advances
|
|
11
21
|
// (an agent ran `rly update`).
|
|
12
22
|
const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
|
|
23
|
+
let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
|
|
24
|
+
|
|
25
|
+
function authHeaders(extra = {}) {
|
|
26
|
+
return access.token ? { ...extra, 'x-relay-share-token': access.token } : extra;
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
// ---------- helpers ----------
|
|
15
30
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -453,10 +468,12 @@
|
|
|
453
468
|
try {
|
|
454
469
|
const r = await fetch('/api/draft', {
|
|
455
470
|
method: 'POST',
|
|
456
|
-
headers: { 'content-type': 'application/json' },
|
|
471
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
457
472
|
body: JSON.stringify(payload()),
|
|
458
473
|
});
|
|
459
474
|
if (!r.ok) throw new Error('draft rejected');
|
|
475
|
+
const body = await r.json().catch(() => null);
|
|
476
|
+
if (body && typeof body.draftRev === 'number') seenDraftRev = body.draftRev;
|
|
460
477
|
saveFailures = 0;
|
|
461
478
|
if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft saved ✓';
|
|
462
479
|
} catch {
|
|
@@ -513,6 +530,12 @@
|
|
|
513
530
|
if (Annotate) {
|
|
514
531
|
Annotate.init({
|
|
515
532
|
initial: state.annotations,
|
|
533
|
+
permissions: {
|
|
534
|
+
add: access.canComment !== false,
|
|
535
|
+
edit: access.canEditComments !== false,
|
|
536
|
+
delete: access.canDeleteComments !== false,
|
|
537
|
+
reply: access.canComment !== false,
|
|
538
|
+
},
|
|
516
539
|
onChange: (list) => {
|
|
517
540
|
state.annotations = list;
|
|
518
541
|
scheduleSave();
|
|
@@ -1019,6 +1042,10 @@
|
|
|
1019
1042
|
sessionStorage.removeItem('relay-updated');
|
|
1020
1043
|
showToast('Board updated by the agent');
|
|
1021
1044
|
}
|
|
1045
|
+
if (sessionStorage.getItem('relay-draft-updated') === '1') {
|
|
1046
|
+
sessionStorage.removeItem('relay-draft-updated');
|
|
1047
|
+
showToast('Board refreshed with shared changes');
|
|
1048
|
+
}
|
|
1022
1049
|
} catch {
|
|
1023
1050
|
// sessionStorage may be unavailable (privacy mode) — non-fatal
|
|
1024
1051
|
}
|
|
@@ -1099,8 +1126,112 @@
|
|
|
1099
1126
|
saveEl = el('span', { class: 'savestate' }, '');
|
|
1100
1127
|
const hint = el('span', { class: 'hint' },
|
|
1101
1128
|
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
1102
|
-
|
|
1103
|
-
app.append(
|
|
1129
|
+
const submitbar = el('div', { class: 'submitbar' }, submitBtn, hint, saveEl);
|
|
1130
|
+
app.append(submitbar);
|
|
1131
|
+
|
|
1132
|
+
function copyText(text, btn) {
|
|
1133
|
+
const done = () => {
|
|
1134
|
+
if (!btn) return;
|
|
1135
|
+
const old = btn.textContent;
|
|
1136
|
+
btn.textContent = 'Copied';
|
|
1137
|
+
setTimeout(() => { btn.textContent = old; }, 1800);
|
|
1138
|
+
};
|
|
1139
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1140
|
+
navigator.clipboard.writeText(text).then(done).catch(() => {});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const ta = document.createElement('textarea');
|
|
1144
|
+
ta.value = text;
|
|
1145
|
+
ta.style.position = 'fixed';
|
|
1146
|
+
ta.style.opacity = '0';
|
|
1147
|
+
document.body.append(ta);
|
|
1148
|
+
ta.select();
|
|
1149
|
+
try { document.execCommand('copy'); done(); } catch {}
|
|
1150
|
+
ta.remove();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function openSharePanel(host) {
|
|
1154
|
+
const panel = el('div', { class: 'share-panel' },
|
|
1155
|
+
el('div', { class: 'share-title' }, 'Share this board'),
|
|
1156
|
+
el('div', { class: 'share-desc' }, 'Choose a permission. The link starts working only after you confirm.'),
|
|
1157
|
+
el('button', { class: 'share-choice', type: 'button', 'data-role': 'collab' },
|
|
1158
|
+
el('span', { class: 'share-choice-title' }, 'Collaborator'),
|
|
1159
|
+
el('span', { class: 'share-choice-sub' }, 'Can edit answers, comment, and submit as owner.')
|
|
1160
|
+
),
|
|
1161
|
+
el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
|
|
1162
|
+
el('span', { class: 'share-choice-title' }, 'Reviewer'),
|
|
1163
|
+
el('span', { class: 'share-choice-sub' }, 'Can add comments only. No submit, answer edits, edit, or delete.')
|
|
1164
|
+
),
|
|
1165
|
+
el('div', { class: 'share-result' })
|
|
1166
|
+
);
|
|
1167
|
+
const close = () => panel.remove();
|
|
1168
|
+
const closeBtn = el('button', { class: 'share-close', type: 'button', 'aria-label': 'Close share panel' }, '×');
|
|
1169
|
+
closeBtn.addEventListener('click', close);
|
|
1170
|
+
panel.prepend(closeBtn);
|
|
1171
|
+
const result = panel.querySelector('.share-result');
|
|
1172
|
+
for (const btn of panel.querySelectorAll('.share-choice')) {
|
|
1173
|
+
btn.addEventListener('click', async () => {
|
|
1174
|
+
const role = btn.getAttribute('data-role');
|
|
1175
|
+
const label = role === 'collab' ? 'collaborator' : 'reviewer';
|
|
1176
|
+
if (!window.confirm(`Activate a ${label} link for same-Wi-Fi devices?`)) return;
|
|
1177
|
+
btn.disabled = true;
|
|
1178
|
+
result.textContent = 'Activating...';
|
|
1179
|
+
try {
|
|
1180
|
+
const res = await fetch('/api/share', {
|
|
1181
|
+
method: 'POST',
|
|
1182
|
+
headers: { 'content-type': 'application/json' },
|
|
1183
|
+
body: JSON.stringify({ role }),
|
|
1184
|
+
});
|
|
1185
|
+
const body = await res.json().catch(() => null);
|
|
1186
|
+
if (!res.ok || !body || !body.url) throw new Error(body && body.error ? body.error : 'share failed');
|
|
1187
|
+
const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
|
|
1188
|
+
copy.addEventListener('click', () => copyText(body.url, copy));
|
|
1189
|
+
result.replaceChildren(
|
|
1190
|
+
el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
|
|
1191
|
+
el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
|
|
1192
|
+
copy
|
|
1193
|
+
);
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
result.textContent = err && err.message ? err.message : 'Could not activate sharing.';
|
|
1196
|
+
} finally {
|
|
1197
|
+
btn.disabled = false;
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
host.append(panel);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function buildFooter() {
|
|
1205
|
+
const footer = el('footer', { class: 'qb-footer' }, el('span', {}, `relay · ${boot.boardId}`));
|
|
1206
|
+
if (access.canShare) {
|
|
1207
|
+
const wrap = el('span', { class: 'share-wrap' });
|
|
1208
|
+
const shareBtn = el('button', { class: 'share-btn', type: 'button' }, 'Share');
|
|
1209
|
+
shareBtn.addEventListener('click', () => {
|
|
1210
|
+
const existing = wrap.querySelector('.share-panel');
|
|
1211
|
+
if (existing) existing.remove();
|
|
1212
|
+
else openSharePanel(wrap);
|
|
1213
|
+
});
|
|
1214
|
+
wrap.append(shareBtn);
|
|
1215
|
+
footer.append(wrap);
|
|
1216
|
+
} else if (access.role === 'review') {
|
|
1217
|
+
footer.append(el('span', { class: 'share-mode' }, 'reviewer · comments only'));
|
|
1218
|
+
} else if (access.role === 'collab') {
|
|
1219
|
+
footer.append(el('span', { class: 'share-mode' }, 'collaborator'));
|
|
1220
|
+
}
|
|
1221
|
+
return footer;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!access.canSubmit) {
|
|
1225
|
+
submitbar.style.display = 'none';
|
|
1226
|
+
app.append(el('div', { class: 'access-note' }, 'Reviewer mode: comments are saved live. Answers and submission are disabled.'));
|
|
1227
|
+
}
|
|
1228
|
+
if (!access.canEditAnswers) {
|
|
1229
|
+
document.documentElement.classList.add('relay-review');
|
|
1230
|
+
for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
|
|
1231
|
+
node.disabled = true;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
app.append(buildFooter());
|
|
1104
1235
|
|
|
1105
1236
|
// ---------- validation & submit ----------
|
|
1106
1237
|
function validate() {
|
|
@@ -1154,7 +1285,7 @@
|
|
|
1154
1285
|
try {
|
|
1155
1286
|
res = await fetch('/api/submit', {
|
|
1156
1287
|
method: 'POST',
|
|
1157
|
-
headers: { 'content-type': 'application/json' },
|
|
1288
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
1158
1289
|
body: JSON.stringify(payload()),
|
|
1159
1290
|
});
|
|
1160
1291
|
} catch (netErr) {
|
|
@@ -1368,6 +1499,23 @@
|
|
|
1368
1499
|
// sessionStorage may be unavailable — the reload still applies the update
|
|
1369
1500
|
}
|
|
1370
1501
|
location.reload();
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
if (body && typeof body.draftRev === 'number' && body.draftRev > seenDraftRev && !submitted && !reloading) {
|
|
1505
|
+
if (userIsComposing()) {
|
|
1506
|
+
showNotice('Another viewer updated this board — it’ll refresh as soon as you finish typing.', 'info');
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
reloading = true;
|
|
1510
|
+
stopHeartbeat();
|
|
1511
|
+
clearTimeout(saveTimer);
|
|
1512
|
+
clearLocalDraft();
|
|
1513
|
+
try {
|
|
1514
|
+
sessionStorage.setItem('relay-draft-updated', '1');
|
|
1515
|
+
} catch {
|
|
1516
|
+
// sessionStorage may be unavailable — the reload still applies the update
|
|
1517
|
+
}
|
|
1518
|
+
location.reload();
|
|
1371
1519
|
}
|
|
1372
1520
|
} catch {
|
|
1373
1521
|
// Lost the live connection (the session ended, or the machine slept). Two
|
package/src/ui/blocks.css
CHANGED
|
@@ -260,6 +260,68 @@
|
|
|
260
260
|
}
|
|
261
261
|
.diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
|
|
262
262
|
|
|
263
|
+
/* diff review: cherry-pick/apply hunk choices on top of the diff renderer */
|
|
264
|
+
.diff-review-head {
|
|
265
|
+
align-items: flex-start;
|
|
266
|
+
}
|
|
267
|
+
.diff-review-topactions {
|
|
268
|
+
display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px;
|
|
269
|
+
}
|
|
270
|
+
.diff-review-status {
|
|
271
|
+
flex: none; border: 1px solid rgba(185, 145, 63, 0.34);
|
|
272
|
+
background: rgba(185, 145, 63, 0.12); color: var(--warn);
|
|
273
|
+
border-radius: 999px; padding: 3px 8px; font-size: 0.72rem; font-weight: 760;
|
|
274
|
+
}
|
|
275
|
+
.diff-review-status.is-complete {
|
|
276
|
+
border-color: rgba(77, 138, 102, 0.42);
|
|
277
|
+
background: rgba(77, 138, 102, 0.14); color: var(--ok);
|
|
278
|
+
}
|
|
279
|
+
.diff-review-btn {
|
|
280
|
+
flex: none; cursor: pointer; font: inherit; font-size: 0.72rem; font-weight: 700;
|
|
281
|
+
color: var(--fg-2); background: var(--card);
|
|
282
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
|
|
283
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease), background 150ms var(--ease);
|
|
284
|
+
}
|
|
285
|
+
.diff-review-btn:hover {
|
|
286
|
+
color: var(--accent); border-color: var(--accent);
|
|
287
|
+
}
|
|
288
|
+
.diff-review-choice.sel {
|
|
289
|
+
color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
|
|
290
|
+
}
|
|
291
|
+
.diff-review-list {
|
|
292
|
+
border: 1px solid var(--border); border-radius: 0 0 10px 10px;
|
|
293
|
+
overflow: hidden; background: var(--bg-sunken);
|
|
294
|
+
}
|
|
295
|
+
.diff-review-hunk + .diff-review-hunk {
|
|
296
|
+
border-top: 1px solid var(--border);
|
|
297
|
+
}
|
|
298
|
+
.diff-review-hunkbar {
|
|
299
|
+
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
|
300
|
+
padding: 6px 8px 6px 12px; background: var(--card); border-bottom: 1px solid var(--border);
|
|
301
|
+
}
|
|
302
|
+
.diff-review-hunkmeta {
|
|
303
|
+
min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
|
304
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
305
|
+
}
|
|
306
|
+
.diff-review-file {
|
|
307
|
+
max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
308
|
+
color: var(--fg-2); font-size: 0.76rem; font-weight: 760;
|
|
309
|
+
}
|
|
310
|
+
.diff-review-header {
|
|
311
|
+
color: var(--muted); font-size: 0.72rem;
|
|
312
|
+
}
|
|
313
|
+
.diff-review-actions {
|
|
314
|
+
flex: none; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px;
|
|
315
|
+
}
|
|
316
|
+
.diff-review-hunk .blk-difftable {
|
|
317
|
+
min-width: 760px;
|
|
318
|
+
}
|
|
319
|
+
@media (max-width: 720px) {
|
|
320
|
+
.diff-review-head, .diff-review-hunkbar { flex-direction: column; align-items: flex-start; }
|
|
321
|
+
.diff-review-topactions, .diff-review-actions { justify-content: flex-start; }
|
|
322
|
+
.diff-review-file { max-width: 100%; }
|
|
323
|
+
}
|
|
324
|
+
|
|
263
325
|
/* ---------- git conflict resolver ---------- */
|
|
264
326
|
.gitconf {
|
|
265
327
|
border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
|
package/src/ui/blocks.js
CHANGED
|
@@ -582,8 +582,159 @@
|
|
|
582
582
|
// Per-file header lines are redundant once we render a filename header bar.
|
|
583
583
|
const DIFF_NOISE = /^(diff --git |index |--- |\+\+\+ )/;
|
|
584
584
|
|
|
585
|
+
function buildDiffReviewHunks(files, block) {
|
|
586
|
+
const hunks = [];
|
|
587
|
+
files.forEach((f) => {
|
|
588
|
+
const name = f.name || (files.length === 1 ? (block.filename || '') : '') || 'file';
|
|
589
|
+
let rows = parseDiff(f.text);
|
|
590
|
+
if (name) rows = rows.filter((r) => !(r.kind === 'meta' && DIFF_NOISE.test(r.text)));
|
|
591
|
+
let current = null;
|
|
592
|
+
const push = () => {
|
|
593
|
+
if (!current) return;
|
|
594
|
+
const hasChange = current.rows.some((r) => r.kind === 'add' || r.kind === 'del');
|
|
595
|
+
if (hasChange) hunks.push(current);
|
|
596
|
+
current = null;
|
|
597
|
+
};
|
|
598
|
+
rows.forEach((r) => {
|
|
599
|
+
if (r.kind === 'hunk') {
|
|
600
|
+
push();
|
|
601
|
+
current = { id: 'h' + (hunks.length + 1), file: name, header: r.text, rows: [r] };
|
|
602
|
+
} else if (current) {
|
|
603
|
+
current.rows.push(r);
|
|
604
|
+
} else if (r.kind === 'add' || r.kind === 'del' || r.kind === 'ctx') {
|
|
605
|
+
current = { id: 'h' + (hunks.length + 1), file: name, header: 'Change', rows: [r] };
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
push();
|
|
609
|
+
});
|
|
610
|
+
return hunks;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function renderDiffReview(block, ctx, blockId, files) {
|
|
614
|
+
const hunks = buildDiffReviewHunks(files, block);
|
|
615
|
+
let view = block.view === 'split' ? 'split' : 'unified';
|
|
616
|
+
const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
|
|
617
|
+
? ctx.edits[blockId]
|
|
618
|
+
: {};
|
|
619
|
+
const choices = prior.hunks && typeof prior.hunks === 'object' ? { ...prior.hunks } : {};
|
|
620
|
+
const buttonsByHunk = new Map();
|
|
621
|
+
const wrap = el('div', { class: 'blk-codewrap blk-diffwrap diff-review' });
|
|
622
|
+
const status = el('span', { class: 'diff-review-status' }, '');
|
|
623
|
+
const list = el('div', { class: 'diff-review-list' });
|
|
624
|
+
|
|
625
|
+
const emit = () => {
|
|
626
|
+
const picked = {};
|
|
627
|
+
hunks.forEach((h) => {
|
|
628
|
+
const r = choices[h.id];
|
|
629
|
+
if (!r || !['apply', 'skip', 'hold'].includes(r.choice)) return;
|
|
630
|
+
picked[h.id] = { choice: r.choice, file: h.file, header: h.header };
|
|
631
|
+
});
|
|
632
|
+
const touched = Object.keys(picked).length;
|
|
633
|
+
const payload = touched
|
|
634
|
+
? {
|
|
635
|
+
type: 'diff-review',
|
|
636
|
+
reviewKind: block.reviewKind || '',
|
|
637
|
+
commit: block.commit || '',
|
|
638
|
+
title: block.title || block.filename || '',
|
|
639
|
+
resolved: touched === hunks.length,
|
|
640
|
+
hunks: picked,
|
|
641
|
+
}
|
|
642
|
+
: null;
|
|
643
|
+
if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
|
|
644
|
+
wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
|
|
645
|
+
bubbles: true,
|
|
646
|
+
detail: { blockId, value: payload },
|
|
647
|
+
}));
|
|
648
|
+
status.textContent = `${touched}/${hunks.length} reviewed`;
|
|
649
|
+
status.classList.toggle('is-complete', hunks.length > 0 && touched === hunks.length);
|
|
650
|
+
for (const h of hunks) {
|
|
651
|
+
const set = buttonsByHunk.get(h.id) || [];
|
|
652
|
+
const chosen = choices[h.id] && choices[h.id].choice;
|
|
653
|
+
set.forEach((b) => b.el.classList.toggle('sel', b.choice === chosen));
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const setChoice = (h, choice) => {
|
|
658
|
+
choices[h.id] = { choice, file: h.file, header: h.header };
|
|
659
|
+
emit();
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const setAll = (choice) => {
|
|
663
|
+
hunks.forEach((h) => {
|
|
664
|
+
choices[h.id] = { choice, file: h.file, header: h.header };
|
|
665
|
+
});
|
|
666
|
+
emit();
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const makeReviewButton = (label, onClick, extraClass = '') => {
|
|
670
|
+
const b = el('button', { type: 'button', class: 'diff-review-btn' + (extraClass ? ' ' + extraClass : '') }, label);
|
|
671
|
+
b.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
672
|
+
b.addEventListener('click', (e) => {
|
|
673
|
+
e.preventDefault();
|
|
674
|
+
e.stopPropagation();
|
|
675
|
+
onClick();
|
|
676
|
+
});
|
|
677
|
+
return b;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const repaint = () => {
|
|
681
|
+
list.replaceChildren();
|
|
682
|
+
hunks.forEach((h) => {
|
|
683
|
+
const actionbar = el('div', { class: 'diff-review-hunkbar' },
|
|
684
|
+
el('div', { class: 'diff-review-hunkmeta' },
|
|
685
|
+
el('span', { class: 'diff-review-file' }, h.file),
|
|
686
|
+
el('span', { class: 'diff-review-header' }, h.header)
|
|
687
|
+
)
|
|
688
|
+
);
|
|
689
|
+
const rowButtons = [
|
|
690
|
+
{ choice: 'apply', label: 'Apply hunk' },
|
|
691
|
+
{ choice: 'skip', label: 'Skip hunk' },
|
|
692
|
+
{ choice: 'hold', label: 'Hold' },
|
|
693
|
+
].map((item) => ({
|
|
694
|
+
choice: item.choice,
|
|
695
|
+
el: makeReviewButton(item.label, () => setChoice(h, item.choice), 'diff-review-choice'),
|
|
696
|
+
}));
|
|
697
|
+
buttonsByHunk.set(h.id, rowButtons);
|
|
698
|
+
actionbar.append(el('div', { class: 'diff-review-actions' }, rowButtons.map((b) => b.el)));
|
|
699
|
+
list.append(el('section', { class: 'diff-review-hunk' },
|
|
700
|
+
actionbar,
|
|
701
|
+
view === 'split' ? buildSplitDiff(h.rows, block.lang) : buildUnifiedDiff(h.rows, block.lang)
|
|
702
|
+
));
|
|
703
|
+
});
|
|
704
|
+
emit();
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const toggle = makeReviewButton('', () => {
|
|
708
|
+
view = view === 'split' ? 'unified' : 'split';
|
|
709
|
+
wrap.classList.toggle('is-split', view === 'split');
|
|
710
|
+
toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
|
|
711
|
+
repaint();
|
|
712
|
+
}, 'blk-difftoggle');
|
|
713
|
+
toggle.title = 'Toggle side-by-side view';
|
|
714
|
+
toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
|
|
715
|
+
|
|
716
|
+
const title = block.title || block.filename || (files.length > 1 ? `${files.length} files changed` : 'Diff review');
|
|
717
|
+
wrap.append(el('div', { class: 'blk-codehead diff-review-head' },
|
|
718
|
+
el('span', { class: 'blk-codename' }, title),
|
|
719
|
+
el('div', { class: 'diff-review-topactions' },
|
|
720
|
+
status,
|
|
721
|
+
makeReviewButton('Apply all', () => setAll('apply')),
|
|
722
|
+
makeReviewButton('Skip all', () => setAll('skip')),
|
|
723
|
+
makeReviewButton('Hold all', () => setAll('hold')),
|
|
724
|
+
toggle
|
|
725
|
+
)
|
|
726
|
+
));
|
|
727
|
+
wrap.classList.toggle('is-split', view === 'split');
|
|
728
|
+
wrap.append(list);
|
|
729
|
+
repaint();
|
|
730
|
+
ctx && ctx.annotate && ctx.annotate.enableTextSelection(list, { blockId, questionId: ctx.questionId });
|
|
731
|
+
attachViewer(wrap, { zoomEl: null, label: 'diff review', comment: wholeBlockComment(ctx, blockId, 'diff review') });
|
|
732
|
+
return wrap;
|
|
733
|
+
}
|
|
734
|
+
|
|
585
735
|
function renderDiff(block, ctx, blockId) {
|
|
586
736
|
const files = splitDiffFiles(block.diff);
|
|
737
|
+
if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
|
|
587
738
|
const named = files.filter((f) => f.name);
|
|
588
739
|
const multi = named.length > 1;
|
|
589
740
|
let view = block.view === 'split' ? 'split' : 'unified';
|
package/src/ui/style.css
CHANGED
|
@@ -341,7 +341,58 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
341
341
|
.done h2 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em; margin: 0 0 8px; }
|
|
342
342
|
.done p { color: var(--muted); margin: 0; }
|
|
343
343
|
|
|
344
|
-
.qb-footer {
|
|
344
|
+
.qb-footer {
|
|
345
|
+
color: var(--muted); font-size: 0.75rem; text-align: center; margin-top: 28px;
|
|
346
|
+
display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
347
|
+
}
|
|
348
|
+
.share-wrap { position: relative; display: inline-flex; }
|
|
349
|
+
.share-btn, .share-copy, .share-choice, .share-close {
|
|
350
|
+
font: inherit;
|
|
351
|
+
}
|
|
352
|
+
.share-btn {
|
|
353
|
+
border: 1px solid var(--border-strong); border-radius: 999px;
|
|
354
|
+
background: var(--card); color: var(--fg-2);
|
|
355
|
+
padding: 5px 12px; cursor: pointer;
|
|
356
|
+
}
|
|
357
|
+
.share-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
358
|
+
.share-panel {
|
|
359
|
+
position: absolute; left: 50%; bottom: calc(100% + 10px); transform: translateX(-50%);
|
|
360
|
+
width: min(360px, calc(100vw - 28px));
|
|
361
|
+
background: var(--card); color: var(--fg);
|
|
362
|
+
border: 1px solid var(--border-strong); border-radius: 12px;
|
|
363
|
+
box-shadow: var(--shadow-lift);
|
|
364
|
+
padding: 16px; text-align: left; z-index: 45;
|
|
365
|
+
}
|
|
366
|
+
.share-close {
|
|
367
|
+
position: absolute; top: 8px; right: 8px;
|
|
368
|
+
border: 0; background: transparent; color: var(--muted);
|
|
369
|
+
cursor: pointer; font-size: 1.1rem; line-height: 1;
|
|
370
|
+
}
|
|
371
|
+
.share-title { font-weight: 650; font-size: 0.95rem; margin-right: 24px; }
|
|
372
|
+
.share-desc { color: var(--muted); font-size: 0.78rem; margin: 4px 20px 12px 0; }
|
|
373
|
+
.share-choice {
|
|
374
|
+
width: 100%; display: flex; flex-direction: column; gap: 2px;
|
|
375
|
+
border: 1px solid var(--border); border-radius: 10px;
|
|
376
|
+
background: var(--bg-sunken); color: var(--fg);
|
|
377
|
+
padding: 10px 12px; margin-top: 8px; cursor: pointer; text-align: left;
|
|
378
|
+
}
|
|
379
|
+
.share-choice:hover { border-color: var(--accent); }
|
|
380
|
+
.share-choice-title { font-weight: 650; }
|
|
381
|
+
.share-choice-sub { color: var(--muted); font-size: 0.76rem; }
|
|
382
|
+
.share-result { margin-top: 12px; color: var(--fg-2); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
383
|
+
.share-ready { color: var(--ok); font-weight: 650; margin-bottom: 4px; }
|
|
384
|
+
.share-url { color: var(--accent); display: block; margin-bottom: 8px; }
|
|
385
|
+
.share-copy {
|
|
386
|
+
border: 0; border-radius: 8px; background: var(--accent); color: var(--accent-fg);
|
|
387
|
+
padding: 7px 12px; cursor: pointer; font-weight: 650;
|
|
388
|
+
}
|
|
389
|
+
.share-mode { color: var(--fg-2); }
|
|
390
|
+
.access-note {
|
|
391
|
+
color: var(--fg-2); background: var(--accent-soft);
|
|
392
|
+
border-radius: 10px; padding: 10px 14px; font-size: 0.86rem;
|
|
393
|
+
}
|
|
394
|
+
.relay-review .ann-del,
|
|
395
|
+
.relay-review .ann-edit { display: none !important; }
|
|
345
396
|
|
|
346
397
|
@media (max-width: 480px) {
|
|
347
398
|
.wrap { padding: 18px 12px 18px; }
|