@khanglvm/relay 0.13.7 → 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 +3 -0
- package/docs/AGENT.md +20 -1
- package/package.json +1 -1
- package/skills/relay/SKILL.md +7 -1
- package/src/cli.js +155 -2
- package/src/mcp-ui/board.js +6 -0
- package/src/server.js +76 -4
- package/src/spec.js +137 -4
- package/src/ui/app.js +6 -0
- package/src/ui/blocks.css +157 -0
- package/src/ui/blocks.js +305 -0
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ unchanged; pick whichever surface fits.
|
|
|
76
76
|
| ASCII architecture art | Mermaid, Graphviz, PlantUML — zoomable, full-screen, even user-editable |
|
|
77
77
|
| Numbers buried in prose | Charts and sortable tables; screenshots and HTML prototypes in a sandbox |
|
|
78
78
|
| "Here's the diff — paste it in your editor" | Side-by-side **diff** blocks, syntax-highlighted **code**, **video** walkthroughs, and **file paths you click to open** in the default app |
|
|
79
|
+
| "Which commits should I pick? Resolve this conflict manually." | `rly git pick` / `rly git cherry-pick` boards and color-coded `git-conflict` resolvers with ours/theirs/custom hunk choices |
|
|
79
80
|
| "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
|
|
80
81
|
| Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
|
|
81
82
|
|
|
@@ -88,6 +89,8 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
88
89
|
| | |
|
|
89
90
|
|---|---|
|
|
90
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; add `--code` to cherry-pick with split code review and per-hunk Apply/Skip/Hold |
|
|
93
|
+
| `rly git conflict [files…]` | auto-detect unmerged conflict files, or open specific local paths, and return resolved content in `result.blockEdits` |
|
|
91
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 |
|
|
92
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` |
|
|
93
96
|
| `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
|
package/docs/AGENT.md
CHANGED
|
@@ -12,7 +12,7 @@ walls of monospace text, or can't show it at all (a terminal can't draw a PNG).
|
|
|
12
12
|
relay renders it in a real browser board. So whenever your output would read better
|
|
13
13
|
than monospace — or is a file/visual the user should look at — show it in relay
|
|
14
14
|
instead of printing it. Fast paths: `rly view file.md` (or `data.csv`), `rly diff`,
|
|
15
|
-
or an `image`/`chart`/`table` block on a board.
|
|
15
|
+
`rly git pick` / `rly git conflict`, or an `image`/`chart`/`table` block on a board.
|
|
16
16
|
|
|
17
17
|
**Use relay even in plan mode** — for any clarifying or decision question (Claude
|
|
18
18
|
Code / Codex), route it through a relay board, **not** the native
|
|
@@ -161,6 +161,25 @@ rly diff --detach # working-tree diff (git args pass through
|
|
|
161
161
|
rly diff --staged --split # --staged, HEAD~1, -- path, …); --split = side-by-side
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
Pick/cherry-pick commits or resolve conflicts on a board:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
rly git pick --limit 20 # checklist: pick/cherry-pick/drop + rank order
|
|
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
|
|
170
|
+
rly git conflict # auto-detect unmerged files with conflict markers
|
|
171
|
+
rly git conflict src/app.js # resolve a specific conflicted file
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
For authored boards, use a `git-conflict` block. It accepts inline conflict
|
|
175
|
+
marker content or a local file path, auto-detects each hunk, and returns user
|
|
176
|
+
choices plus the full resolved file in `result.blockEdits[blockId]`:
|
|
177
|
+
|
|
178
|
+
```jsonc
|
|
179
|
+
{ "type": "git-conflict", "file": "src/app.js" }
|
|
180
|
+
{ "type": "git-conflict", "filename": "app.js", "content": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\n" }
|
|
181
|
+
```
|
|
182
|
+
|
|
164
183
|
## Board spec (JSON)
|
|
165
184
|
|
|
166
185
|
```jsonc
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: relay
|
|
3
|
-
description: "Collect user decisions & answers — choice, multi, yes-no, text, scale, rank
|
|
3
|
+
description: "Collect user decisions & answers — choice, multi, yes-no, text, scale, rank, checklist, allocate, color — and present plans, data, code, git diffs/conflicts, designs, charts, tables, diagrams, images, PDFs, videos, HTML, palettes, typography, and file views on a browser or inline MCP board. Returns JSON answers/notes/comments/annotations/blockEdits on Submit. Use PROACTIVELY for 2+ questions, visual review, plans, metrics, tables, diffs, git pick/cherry-pick/conflict decisions, markdown files, or anything the terminal cannot render well. Use the most specific component instead of prose. Skip single yes/no."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# relay (`rly`)
|
|
@@ -46,6 +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 --code`, `rly git conflict`) |
|
|
49
50
|
| A demo, screen recording or walkthrough | **rly** (`video` block) |
|
|
50
51
|
| Point the user at a file to open (log, capture, report) | **rly** (a clickable local file-link in markdown) |
|
|
51
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 |
|
|
@@ -83,6 +84,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
|
|
|
83
84
|
| `plantuml` | UML (sequence / class / component) |
|
|
84
85
|
| `code` | source / config / command output — highlighted, line numbers, hover-a-line to comment |
|
|
85
86
|
| `diff` | code changes — colored unified/split, multi-file (`rly diff` builds the whole board) |
|
|
87
|
+
| `git-conflict` | conflict-marker files — side-by-side ours/theirs/base with hunk choices; returns resolved content in `result.blockEdits[blockId]` |
|
|
86
88
|
| `image` | screenshots / mockups / renders — zoom+pan; `pins:true` → click-to-drop point comments |
|
|
87
89
|
| `compare` | a before/after pair — draggable divider |
|
|
88
90
|
| `video` | a demo / screen recording / walkthrough |
|
|
@@ -283,6 +285,10 @@ single/multi question.
|
|
|
283
285
|
// ^ a unified / `git diff` text rendered as a colored, line-numbered comparison
|
|
284
286
|
// (no git needed — just paste the diff). "view":"split" = side-by-side; the
|
|
285
287
|
// viewer also has a live Unified⇄Split toggle. "diffFile" loads it from a file.
|
|
288
|
+
{ "type": "git-conflict", "file": "src/app.js" }
|
|
289
|
+
{ "type": "git-conflict", "filename": "app.js", "content": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\n" }
|
|
290
|
+
// ^ conflict markers rendered as a resolver: choose ours/theirs/both/custom per
|
|
291
|
+
// hunk. User choices + full resolved content return in result.blockEdits[blockId].
|
|
286
292
|
{ "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
|
|
287
293
|
{ "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
|
|
288
294
|
// ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
|
package/src/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ const PKG_NAME = PKG_JSON.name; // e.g. "@khanglvm/relay" — the global package
|
|
|
32
32
|
const VALUED_FLAGS = new Set([
|
|
33
33
|
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
34
34
|
'submit-label', 'height', 'limit', 'target', 'id', 'replies',
|
|
35
|
-
'on-result', 'notify-cmd', 'idle-grace', 'scope',
|
|
35
|
+
'on-result', 'notify-cmd', 'idle-grace', 'scope', 'range',
|
|
36
36
|
'host', 'token', 'allow-origin',
|
|
37
37
|
]);
|
|
38
38
|
|
|
@@ -282,6 +282,148 @@ async function cmdDiff(rest) {
|
|
|
282
282
|
return runOrDetach(record, args);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
function runGit(args, message) {
|
|
286
|
+
const res = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
287
|
+
if (res.error) throw new CliError(`could not run git: ${res.error.message}. Is git installed and is this a repo?`);
|
|
288
|
+
if (res.status !== 0) {
|
|
289
|
+
const detail = (res.stderr || res.stdout || '').trim();
|
|
290
|
+
throw new CliError(`${message}: ${detail || `git ${args.join(' ')} exited ${res.status}`}`, res.status || 1);
|
|
291
|
+
}
|
|
292
|
+
return res.stdout || '';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function gitConflictPaths(explicitPaths) {
|
|
296
|
+
if (explicitPaths.length) return explicitPaths;
|
|
297
|
+
const out = runGit(['diff', '--name-only', '--diff-filter=U'], 'git conflict detection failed');
|
|
298
|
+
return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function gitCommitRows(range, limit) {
|
|
302
|
+
const n = Math.max(1, Math.min(200, Number.parseInt(limit, 10) || 20));
|
|
303
|
+
const args = ['log', `-n${n}`, '--date=short', '--pretty=format:%H%x09%h%x09%ad%x09%an%x09%s'];
|
|
304
|
+
if (range) args.push(range);
|
|
305
|
+
return runGit(args, 'git log failed')
|
|
306
|
+
.split(/\r?\n/)
|
|
307
|
+
.filter(Boolean)
|
|
308
|
+
.map((line) => {
|
|
309
|
+
const [sha, short, date, author, ...subjectParts] = line.split('\t');
|
|
310
|
+
const subject = subjectParts.join('\t');
|
|
311
|
+
return { sha, short, date, author, subject };
|
|
312
|
+
})
|
|
313
|
+
.filter((r) => r.sha && r.short);
|
|
314
|
+
}
|
|
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
|
+
|
|
329
|
+
async function cmdGit(rest) {
|
|
330
|
+
const args = parseArgs(rest);
|
|
331
|
+
const modes = new Set(['pick', 'cherry-pick', 'cherrypick', 'conflict', 'conflicts', 'resolve']);
|
|
332
|
+
let mode = 'pick';
|
|
333
|
+
if (args._[0] && modes.has(args._[0])) mode = args._.shift();
|
|
334
|
+
if (mode === 'cherrypick') mode = 'cherry-pick';
|
|
335
|
+
if (mode === 'conflicts' || mode === 'resolve') mode = 'conflict';
|
|
336
|
+
|
|
337
|
+
const blocks = [];
|
|
338
|
+
const questions = [];
|
|
339
|
+
let reviewDiffCount = 0;
|
|
340
|
+
|
|
341
|
+
if (mode === 'pick' || mode === 'cherry-pick') {
|
|
342
|
+
const range = args.range || args._[0] || '';
|
|
343
|
+
const commits = gitCommitRows(range, args.limit);
|
|
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;
|
|
346
|
+
blocks.push({
|
|
347
|
+
type: 'table',
|
|
348
|
+
columns: ['short', 'date', 'author', 'subject'],
|
|
349
|
+
rows: commits.map((c) => ({ short: c.short, date: c.date, author: c.author, subject: c.subject })),
|
|
350
|
+
sortable: true,
|
|
351
|
+
filterable: true,
|
|
352
|
+
exportable: true,
|
|
353
|
+
});
|
|
354
|
+
const options = commits.map((c) => ({
|
|
355
|
+
value: c.sha,
|
|
356
|
+
label: `${c.short} ${c.subject}`,
|
|
357
|
+
description: `${c.date} · ${c.author}`,
|
|
358
|
+
}));
|
|
359
|
+
questions.push({
|
|
360
|
+
id: 'commit_actions',
|
|
361
|
+
type: 'checklist',
|
|
362
|
+
label: mode === 'cherry-pick' ? 'Choose cherry-pick actions' : 'Choose commit actions',
|
|
363
|
+
description: 'Set only the commits you want the agent to act on.',
|
|
364
|
+
options,
|
|
365
|
+
statuses: mode === 'cherry-pick'
|
|
366
|
+
? [{ value: 'cherry-pick', label: 'Cherry-pick', tone: 'ok' }, { value: 'skip', label: 'Skip', tone: 'muted' }, { value: 'hold', label: 'Hold', tone: 'warn' }]
|
|
367
|
+
: [{ value: 'pick', label: 'Pick', tone: 'ok' }, { value: 'cherry-pick', label: 'Cherry-pick', tone: 'warn' }, { value: 'drop', label: 'Drop', tone: 'bad' }],
|
|
368
|
+
});
|
|
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
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const paths = mode === 'conflict' ? gitConflictPaths(args._) : gitConflictPaths([]);
|
|
398
|
+
for (const p of paths) {
|
|
399
|
+
let text = '';
|
|
400
|
+
try { text = fs.readFileSync(path.resolve(p), 'utf8'); } catch { /* normalizeSpec will report unreadable explicit files below */ }
|
|
401
|
+
if (!text || /<<<<<<<[\s\S]*=======[\s\S]*>>>>>>>/.test(text)) {
|
|
402
|
+
blocks.push({ type: 'git-conflict', path: p, filename: p });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (mode === 'conflict' && !blocks.some((b) => b.type === 'git-conflict')) {
|
|
407
|
+
throw new CliError(args._.length
|
|
408
|
+
? 'no conflict markers found in the provided file(s).'
|
|
409
|
+
: 'no unmerged git files with conflict markers were detected.');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const conflictCount = blocks.filter((b) => b.type === 'git-conflict').length;
|
|
413
|
+
const title = args.title || (mode === 'conflict'
|
|
414
|
+
? `Resolve ${conflictCount} git conflict${conflictCount === 1 ? '' : 's'}`
|
|
415
|
+
: mode === 'cherry-pick' ? 'Cherry-pick commits' : 'Pick commits');
|
|
416
|
+
const intro = args.intro || (conflictCount
|
|
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.'
|
|
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.');
|
|
421
|
+
const spec = normalizeSpec({ title, intro, blocks, questions, submitLabel: 'Submit git choices' });
|
|
422
|
+
await assertSpecReady(spec);
|
|
423
|
+
const record = createBoard(spec);
|
|
424
|
+
return runOrDetach(record, args);
|
|
425
|
+
}
|
|
426
|
+
|
|
285
427
|
// `rly view <file.md> [more.md …]` — quick read-only board that renders files
|
|
286
428
|
// with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
|
|
287
429
|
// filename heading separates them. Sugar over `rly show`.
|
|
@@ -907,13 +1049,17 @@ the user should view — put it in relay instead of printing it.**
|
|
|
907
1049
|
diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
|
|
908
1050
|
- **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
|
|
909
1051
|
a diff board), or render diff text in a \`diff\` block — never paste a raw diff.
|
|
1052
|
+
- **Git pick/cherry-pick/conflict decisions** → \`rly git pick\`, \`rly git cherry-pick\`,
|
|
1053
|
+
or \`rly git conflict [files]\`. Conflict boards can also be authored with a
|
|
1054
|
+
\`git-conflict\` block from inline \`content\` or a local \`file\`/\`path\`; user
|
|
1055
|
+
hunk choices come back in \`result.blockEdits[blockId]\` with resolved content.
|
|
910
1056
|
- Point the user at a file with a clickable local path in a markdown block; embed a
|
|
911
1057
|
screen recording with a \`video\` block; when answer choices are visual, give each
|
|
912
1058
|
option its own visual (\`options[].blocks\`) so the user picks by looking.
|
|
913
1059
|
- **There's a purpose-built component for most content — use the MOST SPECIFIC one,
|
|
914
1060
|
never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
|
|
915
1061
|
load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
|
|
916
|
-
\`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
|
|
1062
|
+
\`code\`, \`diff\`, \`git-conflict\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
|
|
917
1063
|
\`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
|
|
918
1064
|
\`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
|
|
919
1065
|
(per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
|
|
@@ -1403,6 +1549,11 @@ USAGE
|
|
|
1403
1549
|
.pdf streams in an inline PDF viewer)
|
|
1404
1550
|
rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
|
|
1405
1551
|
--title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
|
|
1552
|
+
rly git pick [range] board for choosing pick/cherry-pick/drop actions plus commit order
|
|
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
|
|
1555
|
+
rly git conflict [files…] board for resolving conflict-marker files; with no files, auto-detects
|
|
1556
|
+
unmerged git files and returns resolved content in result.blockEdits
|
|
1406
1557
|
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
|
1407
1558
|
--while-active [--idle-grace 180]: keep waiting past the deadline
|
|
1408
1559
|
while the user is still viewing/focused & recently active
|
|
@@ -1479,6 +1630,8 @@ export async function main(argv) {
|
|
|
1479
1630
|
return await cmdView(parseArgs(rest));
|
|
1480
1631
|
case 'diff':
|
|
1481
1632
|
return await cmdDiff(rest);
|
|
1633
|
+
case 'git':
|
|
1634
|
+
return await cmdGit(rest);
|
|
1482
1635
|
case 'reopen':
|
|
1483
1636
|
return await cmdReopen(parseArgs(rest));
|
|
1484
1637
|
case 'rescue':
|
package/src/mcp-ui/board.js
CHANGED
|
@@ -396,6 +396,12 @@
|
|
|
396
396
|
},
|
|
397
397
|
};
|
|
398
398
|
}
|
|
399
|
+
document.addEventListener('relay:block-edit', (e) => {
|
|
400
|
+
const d = e.detail || {};
|
|
401
|
+
if (!d.blockId) return;
|
|
402
|
+
if (d.value === null || d.value === undefined) delete state.blockEdits[d.blockId];
|
|
403
|
+
else state.blockEdits[d.blockId] = d.value;
|
|
404
|
+
});
|
|
399
405
|
function renderBlocks(container, blocks, questionId) {
|
|
400
406
|
if (!Array.isArray(blocks) || !blocks.length) return;
|
|
401
407
|
const target = el('div', { class: 'blocks' });
|
package/src/server.js
CHANGED
|
@@ -184,9 +184,75 @@ function sanitizeAnnotations(value) {
|
|
|
184
184
|
return out;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
function limitString(v, max) {
|
|
188
|
+
return typeof v === 'string' ? v.slice(0, max) : '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sanitizeConflictResolution(value) {
|
|
192
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
193
|
+
if (value.type !== 'git-conflict-resolution') return null;
|
|
194
|
+
const out = {
|
|
195
|
+
type: 'git-conflict-resolution',
|
|
196
|
+
filename: limitString(value.filename, 500),
|
|
197
|
+
file: limitString(value.file, 1200),
|
|
198
|
+
resolved: value.resolved === true,
|
|
199
|
+
resolutions: {},
|
|
200
|
+
content: limitString(value.content, 512 * 1024),
|
|
201
|
+
};
|
|
202
|
+
const raw = value.resolutions && typeof value.resolutions === 'object' && !Array.isArray(value.resolutions)
|
|
203
|
+
? value.resolutions
|
|
204
|
+
: {};
|
|
205
|
+
let n = 0;
|
|
206
|
+
for (const id of Object.keys(raw)) {
|
|
207
|
+
if (n >= 200) break;
|
|
208
|
+
const r = raw[id];
|
|
209
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) continue;
|
|
210
|
+
const choice = limitString(r.choice, 30);
|
|
211
|
+
if (!['ours', 'theirs', 'both', 'custom'].includes(choice)) continue;
|
|
212
|
+
out.resolutions[limitString(id, 80)] = {
|
|
213
|
+
choice,
|
|
214
|
+
value: limitString(r.value, 128 * 1024),
|
|
215
|
+
};
|
|
216
|
+
n++;
|
|
217
|
+
}
|
|
218
|
+
return Object.keys(out.resolutions).length ? out : null;
|
|
219
|
+
}
|
|
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
|
+
|
|
187
252
|
// Validates + sanitizes an incoming blockEdits map (from draft/submit).
|
|
188
|
-
//
|
|
189
|
-
//
|
|
253
|
+
// String values are editable Mermaid source. Structured git-conflict-resolution
|
|
254
|
+
// and diff-review values carry bounded per-hunk choices. Caps prevent a draft
|
|
255
|
+
// from bloating board storage; invalid entries are dropped.
|
|
190
256
|
function sanitizeBlockEdits(value) {
|
|
191
257
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
192
258
|
const out = {};
|
|
@@ -195,8 +261,14 @@ function sanitizeBlockEdits(value) {
|
|
|
195
261
|
if (n >= 50) break;
|
|
196
262
|
if (typeof key !== 'string') continue;
|
|
197
263
|
const v = value[key];
|
|
198
|
-
if (typeof v
|
|
199
|
-
|
|
264
|
+
if (typeof v === 'string') {
|
|
265
|
+
if (v.length > 20000) continue;
|
|
266
|
+
out[key] = v;
|
|
267
|
+
} else {
|
|
268
|
+
const edit = sanitizeConflictResolution(v) || sanitizeDiffReview(v);
|
|
269
|
+
if (!edit) continue;
|
|
270
|
+
out[key] = edit;
|
|
271
|
+
}
|
|
200
272
|
n++;
|
|
201
273
|
}
|
|
202
274
|
return out;
|
package/src/spec.js
CHANGED
|
@@ -41,7 +41,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
|
|
|
41
41
|
|
|
42
42
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
43
43
|
const BLOCK_HEIGHT = { min: 100, max: 2400 };
|
|
44
|
-
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
|
|
44
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'git-conflict', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
|
|
45
45
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
46
46
|
|
|
47
47
|
// code/diff blocks may load their text from a local file (like htmlFile). Caps
|
|
@@ -127,6 +127,103 @@ function readTextSource(block, inlineKey, fileKey, cwd, where) {
|
|
|
127
127
|
return '';
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
function readAnyTextSource(block, inlineKeys, fileKeys, cwd, where, label) {
|
|
131
|
+
for (const key of inlineKeys) {
|
|
132
|
+
if (typeof block[key] === 'string' && block[key] !== '') return { text: block[key], sourceKey: key };
|
|
133
|
+
}
|
|
134
|
+
for (const key of fileKeys) {
|
|
135
|
+
if (typeof block[key] === 'string' && block[key].trim()) {
|
|
136
|
+
const p = path.resolve(cwd, block[key]);
|
|
137
|
+
let buf;
|
|
138
|
+
try {
|
|
139
|
+
buf = fs.readFileSync(p);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new CliError(`${where}: cannot read ${label} "${block[key]}" (resolved: ${p})`);
|
|
142
|
+
}
|
|
143
|
+
if (buf.length > TEXT_FILE_MAX_BYTES) {
|
|
144
|
+
throw new CliError(`${where}: ${label} "${block[key]}" is ${(buf.length / 1024).toFixed(0)}KB — max ${TEXT_FILE_MAX_BYTES / 1024}KB.`);
|
|
145
|
+
}
|
|
146
|
+
return { text: buf.toString('utf8'), sourceKey: key, file: p };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { text: '', sourceKey: '' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function joinConflictLines(lines) {
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseGitConflictContent(content, where) {
|
|
157
|
+
const lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
|
|
158
|
+
const parts = [];
|
|
159
|
+
const conflicts = [];
|
|
160
|
+
let textBuf = [];
|
|
161
|
+
const flushText = () => {
|
|
162
|
+
if (!textBuf.length) return;
|
|
163
|
+
parts.push({ type: 'text', text: joinConflictLines(textBuf) });
|
|
164
|
+
textBuf = [];
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
const start = lines[i].match(/^<<<<<<<(?:\s+(.*))?$/);
|
|
169
|
+
if (!start) {
|
|
170
|
+
textBuf.push(lines[i]);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
flushText();
|
|
175
|
+
const oursLabel = start[1] || 'ours';
|
|
176
|
+
const ours = [];
|
|
177
|
+
const base = [];
|
|
178
|
+
const theirs = [];
|
|
179
|
+
let baseLabel = '';
|
|
180
|
+
let theirsLabel = 'theirs';
|
|
181
|
+
let section = 'ours';
|
|
182
|
+
let closed = false;
|
|
183
|
+
|
|
184
|
+
for (i = i + 1; i < lines.length; i++) {
|
|
185
|
+
const line = lines[i];
|
|
186
|
+
const baseStart = line.match(/^\|\|\|\|\|\|\|(?:\s+(.*))?$/);
|
|
187
|
+
if (baseStart && section === 'ours') {
|
|
188
|
+
baseLabel = baseStart[1] || 'base';
|
|
189
|
+
section = 'base';
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (/^=======$/.test(line) && (section === 'ours' || section === 'base')) {
|
|
193
|
+
section = 'theirs';
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const end = line.match(/^>>>>>>>(?:\s+(.*))?$/);
|
|
197
|
+
if (end && section === 'theirs') {
|
|
198
|
+
theirsLabel = end[1] || theirsLabel;
|
|
199
|
+
closed = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (section === 'ours') ours.push(line);
|
|
203
|
+
else if (section === 'base') base.push(line);
|
|
204
|
+
else theirs.push(line);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!closed) {
|
|
208
|
+
throw new CliError(`${where}: conflict marker starting at line ${conflicts.length + 1} is missing a matching >>>>>>> marker.`);
|
|
209
|
+
}
|
|
210
|
+
const id = `c${conflicts.length + 1}`;
|
|
211
|
+
conflicts.push({
|
|
212
|
+
id,
|
|
213
|
+
oursLabel,
|
|
214
|
+
theirsLabel,
|
|
215
|
+
...(baseLabel ? { baseLabel } : {}),
|
|
216
|
+
ours: joinConflictLines(ours),
|
|
217
|
+
theirs: joinConflictLines(theirs),
|
|
218
|
+
...(baseLabel ? { base: joinConflictLines(base) } : {}),
|
|
219
|
+
});
|
|
220
|
+
parts.push({ type: 'conflict', conflictId: id });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
flushText();
|
|
224
|
+
return { conflicts, parts };
|
|
225
|
+
}
|
|
226
|
+
|
|
130
227
|
// Resolve an image source (shared by the `image` and `compare` blocks): an
|
|
131
228
|
// http(s)/data URL passes through; a local file is read + embedded as a data
|
|
132
229
|
// URI (capped) so the board stays self-contained offline.
|
|
@@ -395,12 +492,41 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
395
492
|
const block = { id, type: 'diff', diff };
|
|
396
493
|
if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
|
|
397
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);
|
|
398
499
|
const view = asStr(rawBlock.view).trim().toLowerCase();
|
|
399
500
|
if (view === 'split' || view === 'unified') block.view = view;
|
|
400
501
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
401
502
|
return block;
|
|
402
503
|
}
|
|
403
504
|
|
|
505
|
+
if (type === 'git-conflict') {
|
|
506
|
+
const src = readAnyTextSource(rawBlock, ['content', 'text'], ['file', 'path', 'src'], cwd, where, 'git conflict file');
|
|
507
|
+
if (!src.text.trim()) {
|
|
508
|
+
throw new CliError(`${where}: git-conflict block needs inline "content"/"text" or a readable local "file"/"path".`);
|
|
509
|
+
}
|
|
510
|
+
const parsed = parseGitConflictContent(src.text, where);
|
|
511
|
+
if (!parsed.conflicts.length) {
|
|
512
|
+
throw new CliError(`${where}: no git conflict markers found (expected <<<<<<<, =======, >>>>>>>).`);
|
|
513
|
+
}
|
|
514
|
+
const filename = asStr(rawBlock.filename ?? rawBlock.name ?? (src.file ? path.basename(src.file) : '')).trim();
|
|
515
|
+
const block = {
|
|
516
|
+
id,
|
|
517
|
+
type: 'git-conflict',
|
|
518
|
+
filename,
|
|
519
|
+
conflicts: parsed.conflicts,
|
|
520
|
+
parts: parsed.parts,
|
|
521
|
+
};
|
|
522
|
+
const lang = asStr(rawBlock.lang).trim() || (filename ? path.extname(filename).slice(1).toLowerCase() : '');
|
|
523
|
+
if (lang) block.lang = lang;
|
|
524
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
525
|
+
if (src.file) block.file = src.file;
|
|
526
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
527
|
+
return block;
|
|
528
|
+
}
|
|
529
|
+
|
|
404
530
|
if (type === 'video') {
|
|
405
531
|
const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
|
|
406
532
|
if (!src) throw new CliError(`${where}: video block needs a "src" (YouTube/Vimeo URL, http(s) media URL, or local file path).`);
|
|
@@ -905,14 +1031,21 @@ const BLOCK_SCHEMA = {
|
|
|
905
1031
|
mdFile: { type: 'string', description: 'markdown: path to a local .md file to load + render instead of inline "md" (e.g. view a README/plan/report). Resolved against the CWD. Quick view of one or more files: `rly view a.md b.md`.' },
|
|
906
1032
|
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display (syntax-highlighted with line numbers).' },
|
|
907
1033
|
codeFile: { type: 'string', description: 'code: path to a local source file to load + display instead of inline "code". Resolved against the CWD; lang defaults from the file extension.' },
|
|
908
|
-
filename: { type: 'string', description: 'code/diff: optional file name/path shown as a header label above the block.' },
|
|
1034
|
+
filename: { type: 'string', description: 'code/diff/git-conflict: optional file name/path shown as a header label above the block.' },
|
|
909
1035
|
editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
|
|
910
1036
|
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
|
911
1037
|
server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
|
|
912
|
-
lang: { type: 'string', description: 'code/diff block: language hint for syntax highlighting (js, ts, py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json, sh, css, html, …).' },
|
|
1038
|
+
lang: { type: 'string', description: 'code/diff/git-conflict block: language hint for syntax highlighting (js, ts, py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json, sh, css, html, …).' },
|
|
913
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.' },
|
|
914
1040
|
diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
|
|
915
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.' },
|
|
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].' },
|
|
1046
|
+
text: { type: 'string', description: 'git-conflict: alias for inline "content".' },
|
|
1047
|
+
file: { type: 'string', description: 'git-conflict: local conflicted file path to load, parse, and resolve. Resolved against the CWD.' },
|
|
1048
|
+
path: { type: 'string', description: 'git-conflict: alias for local conflicted file path.' },
|
|
916
1049
|
config: { type: 'object', description: 'chart: a full Chart.js config object.' },
|
|
917
1050
|
kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
|
|
918
1051
|
labels: { type: 'array', description: 'chart shorthand: x-axis / category labels.' },
|
|
@@ -1053,7 +1186,7 @@ export const SPEC_SCHEMA = {
|
|
|
1053
1186
|
type: 'object',
|
|
1054
1187
|
readOnly: true,
|
|
1055
1188
|
description:
|
|
1056
|
-
'Returned in the result (not part of the input spec). A map blockId→edited mermaid source
|
|
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.',
|
|
1057
1190
|
},
|
|
1058
1191
|
},
|
|
1059
1192
|
anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
|
package/src/ui/app.js
CHANGED
|
@@ -532,6 +532,12 @@
|
|
|
532
532
|
scheduleSave();
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
+
document.addEventListener('relay:block-edit', (e) => {
|
|
536
|
+
const d = e.detail || {};
|
|
537
|
+
if (!d.blockId) return;
|
|
538
|
+
onBlockEdit(d.blockId, d.value);
|
|
539
|
+
});
|
|
540
|
+
|
|
535
541
|
// ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
|
|
536
542
|
// the editable-mermaid plumbing (edits map + onBlockEdit callback).
|
|
537
543
|
function blockCtx(questionId) {
|
package/src/ui/blocks.css
CHANGED
|
@@ -260,6 +260,163 @@
|
|
|
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
|
+
|
|
325
|
+
/* ---------- git conflict resolver ---------- */
|
|
326
|
+
.gitconf {
|
|
327
|
+
border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
|
|
328
|
+
background: var(--card);
|
|
329
|
+
}
|
|
330
|
+
.gitconf-head {
|
|
331
|
+
display: flex; justify-content: space-between; gap: 12px; align-items: flex-start;
|
|
332
|
+
padding: 11px 12px; border-bottom: 1px solid var(--border);
|
|
333
|
+
background: linear-gradient(180deg, var(--card), var(--bg-sunken));
|
|
334
|
+
}
|
|
335
|
+
.gitconf-title {
|
|
336
|
+
font-weight: 750; color: var(--fg);
|
|
337
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
338
|
+
}
|
|
339
|
+
.gitconf-file {
|
|
340
|
+
margin-top: 3px; color: var(--muted); font-size: 0.76rem;
|
|
341
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
342
|
+
overflow-wrap: anywhere;
|
|
343
|
+
}
|
|
344
|
+
.gitconf-status {
|
|
345
|
+
flex: none; border: 1px solid rgba(188, 68, 52, 0.35);
|
|
346
|
+
background: rgba(188, 68, 52, 0.12); color: var(--danger);
|
|
347
|
+
border-radius: 999px; padding: 3px 9px; font-size: 0.74rem; font-weight: 700;
|
|
348
|
+
}
|
|
349
|
+
.gitconf-status.is-complete {
|
|
350
|
+
border-color: rgba(77, 138, 102, 0.42);
|
|
351
|
+
background: rgba(77, 138, 102, 0.14); color: var(--ok);
|
|
352
|
+
}
|
|
353
|
+
.gitconf-card { padding: 12px; border-bottom: 1px solid var(--border); }
|
|
354
|
+
.gitconf-cardhead {
|
|
355
|
+
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
356
|
+
margin-bottom: 9px;
|
|
357
|
+
}
|
|
358
|
+
.gitconf-badge {
|
|
359
|
+
font-size: 0.74rem; font-weight: 760; letter-spacing: 0;
|
|
360
|
+
color: var(--fg); background: var(--bg-sunken); border: 1px solid var(--border);
|
|
361
|
+
border-radius: 999px; padding: 3px 8px;
|
|
362
|
+
}
|
|
363
|
+
.gitconf-hint { color: var(--muted); font-size: 0.76rem; }
|
|
364
|
+
.gitconf-choicebar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 10px; }
|
|
365
|
+
.gitconf-choice {
|
|
366
|
+
cursor: pointer; font: inherit; font-size: 0.76rem; font-weight: 700;
|
|
367
|
+
color: var(--fg-2); background: var(--bg-sunken);
|
|
368
|
+
border: 1px solid var(--border); border-radius: 7px; padding: 5px 9px;
|
|
369
|
+
}
|
|
370
|
+
.gitconf-choice:hover { border-color: var(--accent); color: var(--accent); }
|
|
371
|
+
.gitconf-choice.sel {
|
|
372
|
+
border-color: var(--accent); color: var(--accent);
|
|
373
|
+
background: var(--accent-soft);
|
|
374
|
+
}
|
|
375
|
+
.gitconf-sides {
|
|
376
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
377
|
+
gap: 8px; align-items: stretch;
|
|
378
|
+
}
|
|
379
|
+
.gitconf-side {
|
|
380
|
+
min-width: 0; border: 1px solid var(--border); border-radius: 8px;
|
|
381
|
+
overflow: hidden; background: var(--bg-sunken);
|
|
382
|
+
}
|
|
383
|
+
.gitconf-ours { border-color: rgba(188, 68, 52, 0.28); background: rgba(188, 68, 52, 0.06); }
|
|
384
|
+
.gitconf-base { border-color: rgba(185, 145, 63, 0.32); background: rgba(185, 145, 63, 0.08); }
|
|
385
|
+
.gitconf-theirs { border-color: rgba(77, 138, 102, 0.3); background: rgba(77, 138, 102, 0.07); }
|
|
386
|
+
.gitconf-label {
|
|
387
|
+
padding: 5px 8px; border-bottom: 1px solid var(--border);
|
|
388
|
+
color: var(--fg-2); font-size: 0.72rem; font-weight: 760;
|
|
389
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
390
|
+
overflow-wrap: anywhere;
|
|
391
|
+
}
|
|
392
|
+
.gitconf-pre, .gitconf-resolved {
|
|
393
|
+
margin: 0; padding: 9px 10px; overflow: auto;
|
|
394
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
395
|
+
font-size: 0.8rem; line-height: 1.45; color: var(--fg);
|
|
396
|
+
white-space: pre;
|
|
397
|
+
}
|
|
398
|
+
.gitconf-pre code, .gitconf-resolved code { font: inherit; background: none; color: inherit; }
|
|
399
|
+
.gitconf-custom {
|
|
400
|
+
display: none; width: 100%; box-sizing: border-box; margin-top: 10px;
|
|
401
|
+
border: 1px solid var(--border-strong); border-radius: 8px;
|
|
402
|
+
background: var(--bg); color: var(--fg); padding: 9px 10px;
|
|
403
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
404
|
+
font-size: 0.82rem; line-height: 1.45; resize: vertical;
|
|
405
|
+
}
|
|
406
|
+
.gitconf-body.is-custom .gitconf-custom { display: block; }
|
|
407
|
+
.gitconf-preview { padding: 10px 12px; background: var(--bg-sunken); }
|
|
408
|
+
.gitconf-preview summary {
|
|
409
|
+
cursor: pointer; color: var(--fg-2); font-weight: 720; font-size: 0.82rem;
|
|
410
|
+
}
|
|
411
|
+
.gitconf-resolved {
|
|
412
|
+
margin-top: 8px; border: 1px solid var(--border); border-radius: 8px;
|
|
413
|
+
background: var(--card); max-height: 340px;
|
|
414
|
+
}
|
|
415
|
+
@media (max-width: 640px) {
|
|
416
|
+
.gitconf-head, .gitconf-cardhead { flex-direction: column; align-items: flex-start; }
|
|
417
|
+
.gitconf-sides { grid-template-columns: 1fr; }
|
|
418
|
+
}
|
|
419
|
+
|
|
263
420
|
/* clickable line-number gutter: hover a number to comment on that line */
|
|
264
421
|
.blk-gutter-c { padding: 14px 0; }
|
|
265
422
|
.blk-gutter-c .blk-gutterline {
|
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';
|
|
@@ -649,6 +800,156 @@
|
|
|
649
800
|
attachViewer(wrap, { zoomEl: null, label: 'diff', comment: wholeBlockComment(ctx, blockId, 'diff') });
|
|
650
801
|
return wrap;
|
|
651
802
|
}
|
|
803
|
+
|
|
804
|
+
// ---------- git conflict resolver ----------
|
|
805
|
+
function markerConflictText(h) {
|
|
806
|
+
const chunks = [
|
|
807
|
+
'<<<<<<< ' + (h.oursLabel || 'ours'),
|
|
808
|
+
h.ours || '',
|
|
809
|
+
];
|
|
810
|
+
if (h.baseLabel) chunks.push('||||||| ' + h.baseLabel, h.base || '');
|
|
811
|
+
chunks.push('=======', h.theirs || '', '>>>>>>> ' + (h.theirsLabel || 'theirs'));
|
|
812
|
+
return chunks.join('\n');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function conflictChoiceText(h, choice, custom) {
|
|
816
|
+
if (choice === 'ours') return h.ours || '';
|
|
817
|
+
if (choice === 'theirs') return h.theirs || '';
|
|
818
|
+
if (choice === 'both') {
|
|
819
|
+
const left = h.ours || '';
|
|
820
|
+
const right = h.theirs || '';
|
|
821
|
+
return left && right ? left + '\n' + right : left || right;
|
|
822
|
+
}
|
|
823
|
+
if (choice === 'custom') return custom || '';
|
|
824
|
+
return markerConflictText(h);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function joinResolvedParts(block, resolutions) {
|
|
828
|
+
const byId = new Map((block.conflicts || []).map((h) => [h.id, h]));
|
|
829
|
+
return (block.parts || []).map((part) => {
|
|
830
|
+
if (!part || part.type === 'text') return part && part.text ? part.text : '';
|
|
831
|
+
const h = byId.get(part.conflictId);
|
|
832
|
+
if (!h) return '';
|
|
833
|
+
const r = resolutions && resolutions[h.id] ? resolutions[h.id] : {};
|
|
834
|
+
return conflictChoiceText(h, r.choice, r.value);
|
|
835
|
+
}).join('\n');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function renderConflictCode(text, lang) {
|
|
839
|
+
const pre = el('pre', { class: 'gitconf-pre' });
|
|
840
|
+
const code = el('code');
|
|
841
|
+
code.innerHTML = tintCode(text || ' ', lang);
|
|
842
|
+
pre.append(code);
|
|
843
|
+
return pre;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function renderGitConflict(block, ctx, blockId) {
|
|
847
|
+
const conflicts = Array.isArray(block.conflicts) ? block.conflicts : [];
|
|
848
|
+
const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
|
|
849
|
+
? ctx.edits[blockId]
|
|
850
|
+
: {};
|
|
851
|
+
const resolutions = prior.resolutions && typeof prior.resolutions === 'object' ? { ...prior.resolutions } : {};
|
|
852
|
+
const wrap = el('div', { class: 'gitconf' });
|
|
853
|
+
const count = conflicts.length;
|
|
854
|
+
const title = block.title || block.filename || 'Git conflict';
|
|
855
|
+
const status = el('span', { class: 'gitconf-status' }, `${count} conflict${count === 1 ? '' : 's'}`);
|
|
856
|
+
const resolvedPreview = el('pre', { class: 'gitconf-resolved' }, el('code'));
|
|
857
|
+
|
|
858
|
+
function emit() {
|
|
859
|
+
const touched = Object.keys(resolutions).filter((id) => resolutions[id] && resolutions[id].choice);
|
|
860
|
+
const payload = touched.length
|
|
861
|
+
? {
|
|
862
|
+
type: 'git-conflict-resolution',
|
|
863
|
+
filename: block.filename || '',
|
|
864
|
+
file: block.file || '',
|
|
865
|
+
resolved: touched.length === count,
|
|
866
|
+
resolutions,
|
|
867
|
+
content: joinResolvedParts(block, resolutions),
|
|
868
|
+
}
|
|
869
|
+
: null;
|
|
870
|
+
if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
|
|
871
|
+
wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
|
|
872
|
+
bubbles: true,
|
|
873
|
+
detail: { blockId, value: payload },
|
|
874
|
+
}));
|
|
875
|
+
resolvedPreview.querySelector('code').textContent = joinResolvedParts(block, resolutions);
|
|
876
|
+
status.textContent = `${touched.length}/${count} resolved`;
|
|
877
|
+
status.classList.toggle('is-complete', touched.length === count);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
wrap.append(el('div', { class: 'gitconf-head' },
|
|
881
|
+
el('div', {},
|
|
882
|
+
el('div', { class: 'gitconf-title' }, title),
|
|
883
|
+
block.file ? el('div', { class: 'gitconf-file' }, block.file) : null),
|
|
884
|
+
status
|
|
885
|
+
));
|
|
886
|
+
|
|
887
|
+
conflicts.forEach((h, idx) => {
|
|
888
|
+
const body = el('div', { class: 'gitconf-body' });
|
|
889
|
+
const custom = el('textarea', {
|
|
890
|
+
class: 'gitconf-custom',
|
|
891
|
+
spellcheck: 'false',
|
|
892
|
+
rows: '5',
|
|
893
|
+
placeholder: 'Write the resolved content for this hunk',
|
|
894
|
+
});
|
|
895
|
+
const buttons = [];
|
|
896
|
+
const setChoice = (choice) => {
|
|
897
|
+
const current = resolutions[h.id] || {};
|
|
898
|
+
const next = { choice, value: choice === 'custom' ? custom.value : conflictChoiceText(h, choice, custom.value) };
|
|
899
|
+
resolutions[h.id] = next;
|
|
900
|
+
for (const b of buttons) b.el.classList.toggle('sel', b.choice === choice);
|
|
901
|
+
body.classList.toggle('is-custom', choice === 'custom');
|
|
902
|
+
emit();
|
|
903
|
+
};
|
|
904
|
+
const makeButton = (choice, label) => {
|
|
905
|
+
const b = el('button', { type: 'button', class: 'gitconf-choice' }, label);
|
|
906
|
+
b.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
907
|
+
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChoice(choice); });
|
|
908
|
+
buttons.push({ choice, el: b });
|
|
909
|
+
return b;
|
|
910
|
+
};
|
|
911
|
+
const existing = resolutions[h.id] || {};
|
|
912
|
+
if (existing.choice === 'custom') custom.value = existing.value || '';
|
|
913
|
+
else custom.value = conflictChoiceText(h, existing.choice || 'ours', '');
|
|
914
|
+
custom.addEventListener('input', () => setChoice('custom'));
|
|
915
|
+
|
|
916
|
+
body.append(el('div', { class: 'gitconf-choicebar' },
|
|
917
|
+
makeButton('ours', 'Use ours'),
|
|
918
|
+
makeButton('theirs', 'Use theirs'),
|
|
919
|
+
makeButton('both', 'Keep both'),
|
|
920
|
+
makeButton('custom', 'Custom')
|
|
921
|
+
));
|
|
922
|
+
body.append(el('div', { class: 'gitconf-sides' },
|
|
923
|
+
el('section', { class: 'gitconf-side gitconf-ours' },
|
|
924
|
+
el('div', { class: 'gitconf-label' }, h.oursLabel || 'ours'),
|
|
925
|
+
renderConflictCode(h.ours || '', block.lang)),
|
|
926
|
+
h.baseLabel ? el('section', { class: 'gitconf-side gitconf-base' },
|
|
927
|
+
el('div', { class: 'gitconf-label' }, h.baseLabel),
|
|
928
|
+
renderConflictCode(h.base || '', block.lang)) : null,
|
|
929
|
+
el('section', { class: 'gitconf-side gitconf-theirs' },
|
|
930
|
+
el('div', { class: 'gitconf-label' }, h.theirsLabel || 'theirs'),
|
|
931
|
+
renderConflictCode(h.theirs || '', block.lang))
|
|
932
|
+
));
|
|
933
|
+
body.append(custom);
|
|
934
|
+
|
|
935
|
+
const card = el('section', { class: 'gitconf-card' },
|
|
936
|
+
el('div', { class: 'gitconf-cardhead' },
|
|
937
|
+
el('span', { class: 'gitconf-badge' }, `Conflict ${idx + 1}`),
|
|
938
|
+
el('span', { class: 'gitconf-hint' }, 'Choose a side, keep both, or write the final hunk.')),
|
|
939
|
+
body
|
|
940
|
+
);
|
|
941
|
+
wrap.append(card);
|
|
942
|
+
if (existing.choice) setChoice(existing.choice);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
wrap.append(el('details', { class: 'gitconf-preview' },
|
|
946
|
+
el('summary', {}, 'Resolved file preview'),
|
|
947
|
+
resolvedPreview
|
|
948
|
+
));
|
|
949
|
+
emit();
|
|
950
|
+
ctx && ctx.annotate && ctx.annotate.enableTextSelection(wrap, { blockId, questionId: ctx.questionId });
|
|
951
|
+
return wrap;
|
|
952
|
+
}
|
|
652
953
|
// ---------- video (YouTube/Vimeo embed, local stream, or direct URL) ----------
|
|
653
954
|
function renderVideo(block, ctx, blockId) {
|
|
654
955
|
const wrap = el('div', { class: 'blk-videowrap' });
|
|
@@ -2216,6 +2517,10 @@
|
|
|
2216
2517
|
inner = renderDiff(block, ctx, blockId);
|
|
2217
2518
|
wrapper.append(inner);
|
|
2218
2519
|
break;
|
|
2520
|
+
case 'git-conflict':
|
|
2521
|
+
inner = renderGitConflict(block, ctx, blockId);
|
|
2522
|
+
wrapper.append(inner);
|
|
2523
|
+
break;
|
|
2219
2524
|
case 'video':
|
|
2220
2525
|
inner = renderVideo(block, ctx, blockId);
|
|
2221
2526
|
wrapper.append(inner);
|