@khanglvm/relay 0.13.8 → 0.13.9
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 +1 -1
- package/docs/AGENT.md +1 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +1 -1
- package/src/cli.js +45 -8
- package/src/server.js +33 -2
- package/src/spec.js +8 -1
- package/src/ui/blocks.css +62 -0
- package/src/ui/blocks.js +151 -0
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
89
89
|
| | |
|
|
90
90
|
|---|---|
|
|
91
91
|
| `rly help` | every command at a glance |
|
|
92
|
-
| `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board |
|
|
92
|
+
| `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board; add `--code` to cherry-pick with split code review and per-hunk Apply/Skip/Hold |
|
|
93
93
|
| `rly git conflict [files…]` | auto-detect unmerged conflict files, or open specific local paths, and return resolved content in `result.blockEdits` |
|
|
94
94
|
| `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
|
|
95
95
|
| `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
|
package/docs/AGENT.md
CHANGED
|
@@ -166,6 +166,7 @@ Pick/cherry-pick commits or resolve conflicts on a board:
|
|
|
166
166
|
```sh
|
|
167
167
|
rly git pick --limit 20 # checklist: pick/cherry-pick/drop + rank order
|
|
168
168
|
rly git cherry-pick main..feature # checklist: cherry-pick/skip/hold + rank order
|
|
169
|
+
rly git cherry-pick --code main..feature # adds split code review with Apply/Skip/Hold per hunk
|
|
169
170
|
rly git conflict # auto-detect unmerged files with conflict markers
|
|
170
171
|
rly git conflict src/app.js # resolve a specific conflicted file
|
|
171
172
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.9",
|
|
4
4
|
"description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -46,7 +46,7 @@ at — show it in a relay board instead of printing it.**
|
|
|
46
46
|
| Gather requirements / plan approval / feedback round | **rly** |
|
|
47
47
|
| Architecture or flow that benefits from a diagram | **rly** (mermaid block) |
|
|
48
48
|
| "Show me the diff" / git diff / code changes / before-after | **rly** (`diff` block — run `git diff`, render it; never dump it in the terminal) |
|
|
49
|
-
| Pick/cherry-pick commits or resolve conflict files | **rly git** (`rly git pick`, `rly git cherry-pick`, `rly git conflict`) |
|
|
49
|
+
| Pick/cherry-pick commits or resolve conflict files | **rly git** (`rly git pick`, `rly git cherry-pick --code`, `rly git conflict`) |
|
|
50
50
|
| A demo, screen recording or walkthrough | **rly** (`video` block) |
|
|
51
51
|
| Point the user at a file to open (log, capture, report) | **rly** (a clickable local file-link in markdown) |
|
|
52
52
|
| Let the user read a markdown file (README, plan, report) | **rly view file.md** (or a `markdown` block with `mdFile`) — never dump the file into the terminal |
|
package/src/cli.js
CHANGED
|
@@ -313,6 +313,19 @@ function gitCommitRows(range, limit) {
|
|
|
313
313
|
.filter((r) => r.sha && r.short);
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
function gitCommitDiff(commit) {
|
|
317
|
+
return runGit([
|
|
318
|
+
'--no-pager',
|
|
319
|
+
'show',
|
|
320
|
+
'--format=',
|
|
321
|
+
'--patch',
|
|
322
|
+
'--find-renames',
|
|
323
|
+
'--find-copies',
|
|
324
|
+
'--no-ext-diff',
|
|
325
|
+
commit,
|
|
326
|
+
], `git show ${commit} failed`).trimEnd();
|
|
327
|
+
}
|
|
328
|
+
|
|
316
329
|
async function cmdGit(rest) {
|
|
317
330
|
const args = parseArgs(rest);
|
|
318
331
|
const modes = new Set(['pick', 'cherry-pick', 'cherrypick', 'conflict', 'conflicts', 'resolve']);
|
|
@@ -323,11 +336,13 @@ async function cmdGit(rest) {
|
|
|
323
336
|
|
|
324
337
|
const blocks = [];
|
|
325
338
|
const questions = [];
|
|
339
|
+
let reviewDiffCount = 0;
|
|
326
340
|
|
|
327
341
|
if (mode === 'pick' || mode === 'cherry-pick') {
|
|
328
342
|
const range = args.range || args._[0] || '';
|
|
329
343
|
const commits = gitCommitRows(range, args.limit);
|
|
330
344
|
if (!commits.length) throw new CliError(`git log${range ? ` ${range}` : ''} produced no commits to show.`);
|
|
345
|
+
const withCode = args.code === true || args.patch === true || args.diff === true;
|
|
331
346
|
blocks.push({
|
|
332
347
|
type: 'table',
|
|
333
348
|
columns: ['short', 'date', 'author', 'subject'],
|
|
@@ -351,13 +366,32 @@ async function cmdGit(rest) {
|
|
|
351
366
|
? [{ value: 'cherry-pick', label: 'Cherry-pick', tone: 'ok' }, { value: 'skip', label: 'Skip', tone: 'muted' }, { value: 'hold', label: 'Hold', tone: 'warn' }]
|
|
352
367
|
: [{ value: 'pick', label: 'Pick', tone: 'ok' }, { value: 'cherry-pick', label: 'Cherry-pick', tone: 'warn' }, { value: 'drop', label: 'Drop', tone: 'bad' }],
|
|
353
368
|
});
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
369
|
+
if (options.length > 1) {
|
|
370
|
+
questions.push({
|
|
371
|
+
id: 'commit_order',
|
|
372
|
+
type: 'rank',
|
|
373
|
+
label: mode === 'cherry-pick' ? 'Cherry-pick order' : 'Preferred commit order',
|
|
374
|
+
description: 'Drag or move commits into the order the agent should apply them.',
|
|
375
|
+
options,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (withCode) {
|
|
379
|
+
for (const c of commits) {
|
|
380
|
+
const diff = gitCommitDiff(c.sha);
|
|
381
|
+
if (!diff.trim()) continue;
|
|
382
|
+
blocks.push({
|
|
383
|
+
type: 'diff',
|
|
384
|
+
title: `${c.short} ${c.subject}`,
|
|
385
|
+
filename: `${c.short} ${c.subject}`,
|
|
386
|
+
commit: c.sha,
|
|
387
|
+
reviewKind: mode,
|
|
388
|
+
review: true,
|
|
389
|
+
view: 'split',
|
|
390
|
+
diff,
|
|
391
|
+
});
|
|
392
|
+
reviewDiffCount++;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
361
395
|
}
|
|
362
396
|
|
|
363
397
|
const paths = mode === 'conflict' ? gitConflictPaths(args._) : gitConflictPaths([]);
|
|
@@ -381,7 +415,9 @@ async function cmdGit(rest) {
|
|
|
381
415
|
: mode === 'cherry-pick' ? 'Cherry-pick commits' : 'Pick commits');
|
|
382
416
|
const intro = args.intro || (conflictCount
|
|
383
417
|
? 'Resolve each conflict on the board. The submitted result includes result.blockEdits with per-hunk choices and a full resolved file preview for every conflict block.'
|
|
384
|
-
:
|
|
418
|
+
: reviewDiffCount
|
|
419
|
+
? 'Review commit code hunks on the board. Apply, skip, or hold each hunk; submit returns commit actions plus diff-review hunk choices as JSON.'
|
|
420
|
+
: 'Pick commit actions and order on the board. Submit returns the selected actions and rank order as JSON.');
|
|
385
421
|
const spec = normalizeSpec({ title, intro, blocks, questions, submitLabel: 'Submit git choices' });
|
|
386
422
|
await assertSpecReady(spec);
|
|
387
423
|
const record = createBoard(spec);
|
|
@@ -1515,6 +1551,7 @@ USAGE
|
|
|
1515
1551
|
--title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
|
|
1516
1552
|
rly git pick [range] board for choosing pick/cherry-pick/drop actions plus commit order
|
|
1517
1553
|
rly git cherry-pick [range] board for choosing commits and cherry-pick order
|
|
1554
|
+
add --code for split code view with per-hunk Apply/Skip/Hold
|
|
1518
1555
|
rly git conflict [files…] board for resolving conflict-marker files; with no files, auto-detects
|
|
1519
1556
|
unmerged git files and returns resolved content in result.blockEdits
|
|
1520
1557
|
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
package/src/server.js
CHANGED
|
@@ -218,9 +218,40 @@ function sanitizeConflictResolution(value) {
|
|
|
218
218
|
return Object.keys(out.resolutions).length ? out : null;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
function sanitizeDiffReview(value) {
|
|
222
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
223
|
+
if (value.type !== 'diff-review') return null;
|
|
224
|
+
const out = {
|
|
225
|
+
type: 'diff-review',
|
|
226
|
+
reviewKind: limitString(value.reviewKind, 80),
|
|
227
|
+
commit: limitString(value.commit, 120),
|
|
228
|
+
title: limitString(value.title, 500),
|
|
229
|
+
resolved: value.resolved === true,
|
|
230
|
+
hunks: {},
|
|
231
|
+
};
|
|
232
|
+
const raw = value.hunks && typeof value.hunks === 'object' && !Array.isArray(value.hunks)
|
|
233
|
+
? value.hunks
|
|
234
|
+
: {};
|
|
235
|
+
let n = 0;
|
|
236
|
+
for (const id of Object.keys(raw)) {
|
|
237
|
+
if (n >= 500) break;
|
|
238
|
+
const h = raw[id];
|
|
239
|
+
if (!h || typeof h !== 'object' || Array.isArray(h)) continue;
|
|
240
|
+
const choice = limitString(h.choice, 30);
|
|
241
|
+
if (!['apply', 'skip', 'hold'].includes(choice)) continue;
|
|
242
|
+
out.hunks[limitString(id, 80)] = {
|
|
243
|
+
choice,
|
|
244
|
+
file: limitString(h.file, 1200),
|
|
245
|
+
header: limitString(h.header, 500),
|
|
246
|
+
};
|
|
247
|
+
n++;
|
|
248
|
+
}
|
|
249
|
+
return Object.keys(out.hunks).length ? out : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
221
252
|
// Validates + sanitizes an incoming blockEdits map (from draft/submit).
|
|
222
253
|
// String values are editable Mermaid source. Structured git-conflict-resolution
|
|
223
|
-
// values carry per-hunk choices
|
|
254
|
+
// and diff-review values carry bounded per-hunk choices. Caps prevent a draft
|
|
224
255
|
// from bloating board storage; invalid entries are dropped.
|
|
225
256
|
function sanitizeBlockEdits(value) {
|
|
226
257
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
@@ -234,7 +265,7 @@ function sanitizeBlockEdits(value) {
|
|
|
234
265
|
if (v.length > 20000) continue;
|
|
235
266
|
out[key] = v;
|
|
236
267
|
} else {
|
|
237
|
-
const edit = sanitizeConflictResolution(v);
|
|
268
|
+
const edit = sanitizeConflictResolution(v) || sanitizeDiffReview(v);
|
|
238
269
|
if (!edit) continue;
|
|
239
270
|
out[key] = edit;
|
|
240
271
|
}
|
package/src/spec.js
CHANGED
|
@@ -492,6 +492,10 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
492
492
|
const block = { id, type: 'diff', diff };
|
|
493
493
|
if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
|
|
494
494
|
if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
|
|
495
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
496
|
+
if (rawBlock.review === true) block.review = true;
|
|
497
|
+
if (rawBlock.reviewKind !== undefined) block.reviewKind = asStr(rawBlock.reviewKind);
|
|
498
|
+
if (rawBlock.commit !== undefined) block.commit = asStr(rawBlock.commit);
|
|
495
499
|
const view = asStr(rawBlock.view).trim().toLowerCase();
|
|
496
500
|
if (view === 'split' || view === 'unified') block.view = view;
|
|
497
501
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
@@ -1035,6 +1039,9 @@ const BLOCK_SCHEMA = {
|
|
|
1035
1039
|
diff: { type: 'string', description: 'diff: a unified diff (git diff / diff -u output) — rendered as a colored, line-numbered comparison with +added / −removed / context rows and file/hunk headers. No git needed; just write/paste the diff text.' },
|
|
1036
1040
|
diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
|
|
1037
1041
|
view: { type: 'string', enum: ['unified', 'split'], description: 'diff: initial layout — "unified" (default, one column) or "split" (side-by-side old vs new). The viewer also has a live toggle either way.' },
|
|
1042
|
+
review: { type: 'boolean', description: 'diff: when true, render per-hunk Apply / Skip / Hold controls and return choices in result.blockEdits[blockId] as {type:"diff-review", hunks:{...}}. Used by `rly git cherry-pick --code`.' },
|
|
1043
|
+
reviewKind: { type: 'string', description: 'diff review: optional label for the review workflow, such as "cherry-pick" or "pick".' },
|
|
1044
|
+
commit: { type: 'string', description: 'diff review: optional commit SHA associated with this diff.' },
|
|
1038
1045
|
content: { type: 'string', description: 'git-conflict: inline file content containing conflict markers (<<<<<<< / ======= / >>>>>>>). The board auto-detects each hunk and returns resolutions in result.blockEdits[blockId].' },
|
|
1039
1046
|
text: { type: 'string', description: 'git-conflict: alias for inline "content".' },
|
|
1040
1047
|
file: { type: 'string', description: 'git-conflict: local conflicted file path to load, parse, and resolve. Resolved against the CWD.' },
|
|
@@ -1179,7 +1186,7 @@ export const SPEC_SCHEMA = {
|
|
|
1179
1186
|
type: 'object',
|
|
1180
1187
|
readOnly: true,
|
|
1181
1188
|
description:
|
|
1182
|
-
'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. null when the user made no edits.',
|
|
1189
|
+
'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. diff review blocks return {type:"diff-review", hunks, resolved, commit}. null when the user made no edits.',
|
|
1183
1190
|
},
|
|
1184
1191
|
},
|
|
1185
1192
|
anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
|
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';
|