@khanglvm/relay 0.9.0 → 0.10.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 +29 -0
- package/docs/AGENT.md +35 -3
- package/package.json +2 -2
- package/src/cli.js +41 -0
- package/src/open.js +12 -0
- package/src/server.js +193 -0
- package/src/spec.js +126 -8
- package/src/store.js +4 -0
- package/src/ui/app.js +295 -31
- package/src/ui/blocks.css +144 -2
- package/src/ui/blocks.js +407 -60
- package/src/ui/style.css +47 -1
package/src/ui/blocks.js
CHANGED
|
@@ -37,21 +37,80 @@
|
|
|
37
37
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// ---------- local file paths → click-to-open links ----------
|
|
41
|
+
// Agents routinely write a path (~/clip.mp4, ./src/app.js, /abs/file) and the
|
|
42
|
+
// user expects to click it open, not copy it into a terminal. We turn such
|
|
43
|
+
// paths into links that POST to /api/open, where the server opens them in the
|
|
44
|
+
// OS default app. FILE_PATH_RE / looksLikeLocalPath MUST match the same logic
|
|
45
|
+
// in server.js so the page only links what the server will agree to open.
|
|
46
|
+
// The body class also excludes \0 (the placeholder delimiter mdInline uses
|
|
47
|
+
// below) so a path butted against a stashed span can't swallow it; \0 never
|
|
48
|
+
// appears in real text, so this doesn't change which real paths match.
|
|
49
|
+
const FILE_PATH_RE =
|
|
50
|
+
/(?<![\w@:./])(?:file:\/\/\/?[^\s)<>"'`*\0]+|~\/[^\s)<>"'`*\0]+|\.{1,2}\/[^\s)<>"'`*\0]+|\/[^\s)<>"'`*\0]+|[A-Za-z]:[\\/][^\s)<>"'`*\0]+)/g;
|
|
51
|
+
|
|
52
|
+
// Note: candidates here are HTML-escaped (esc() ran first), but escaping only
|
|
53
|
+
// touches & < > " ' — none of which appear in a path's structural prefix, so
|
|
54
|
+
// these tests are safe to run on the escaped text. Mirrors server.js.
|
|
55
|
+
function looksLikeLocalPath(s) {
|
|
56
|
+
if (typeof s !== 'string') return false;
|
|
57
|
+
const t = s.trim();
|
|
58
|
+
if (!t || /\s/.test(t)) return false;
|
|
59
|
+
if (/^file:\/\//i.test(t)) return true;
|
|
60
|
+
if (/^[A-Za-z]:[\\/]/.test(t)) return true; // windows drive
|
|
61
|
+
if (t === '~' || /^~\//.test(t)) return true;
|
|
62
|
+
if (/^\.\.?\//.test(t)) return true; // ./ or ../
|
|
63
|
+
if (t.startsWith('/')) return /\/[^/]+\/[^/]/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Builds the <a> for a file path. `raw` and `label` are already escaped, so
|
|
68
|
+
// they embed safely inside the attribute and text. codeStyle keeps the
|
|
69
|
+
// monospace look when the path came from a `backtick` span.
|
|
70
|
+
function fileLinkHtml(raw, label, codeStyle) {
|
|
71
|
+
const cls = 'rly-filelink' + (codeStyle ? ' rly-filelink-code' : '');
|
|
72
|
+
return (
|
|
73
|
+
`<a class="${cls}" role="link" tabindex="0" data-rly-open="${raw}" ` +
|
|
74
|
+
`title="Open ${raw} in the default app">` +
|
|
75
|
+
`<span class="rly-filelink-ico" aria-hidden="true"></span>` +
|
|
76
|
+
`<span class="rly-filelink-txt">${label}</span></a>`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
// ---------- markdown mini renderer (NO library) ----------
|
|
41
81
|
// Escape FIRST, then apply transforms on the already-escaped text. Because
|
|
42
82
|
// <, >, & are gone, our generated tags are the only real tags in the output.
|
|
43
83
|
function mdInline(escaped) {
|
|
84
|
+
// Stash code/link/file-link HTML behind \0-delimited placeholders so the
|
|
85
|
+
// later path-autolink and bold/italic passes can't re-tokenize inside them.
|
|
86
|
+
// \0 never appears in real text, so the placeholders can't collide with it.
|
|
87
|
+
const stash = [];
|
|
88
|
+
const keep = (html) => '\0' + (stash.push(html) - 1) + '\0';
|
|
89
|
+
|
|
44
90
|
let s = escaped;
|
|
45
|
-
// inline code first so its contents aren't treated as bold/italic/links
|
|
46
|
-
|
|
47
|
-
|
|
91
|
+
// inline code first so its contents aren't treated as bold/italic/links —
|
|
92
|
+
// a backtick span that is ITSELF a lone path opens instead of just showing.
|
|
93
|
+
s = s.replace(/`([^`]+)`/g, (_m, c) =>
|
|
94
|
+
looksLikeLocalPath(c) ? keep(fileLinkHtml(c.trim(), c, true)) : keep(`<code>${c}</code>`)
|
|
95
|
+
);
|
|
96
|
+
// links [text](url) — url is already escaped; a local-path target becomes a
|
|
97
|
+
// click-to-open link, otherwise a normal link (guarding odd schemes).
|
|
48
98
|
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
|
|
99
|
+
if (looksLikeLocalPath(url)) return keep(fileLinkHtml(url, text, false));
|
|
49
100
|
const safe = /^(https?:|mailto:|\/|#|\.)/i.test(url) ? url : '#';
|
|
50
|
-
return `<a href="${safe}" target="_blank" rel="noopener">${text}</a
|
|
101
|
+
return keep(`<a href="${safe}" target="_blank" rel="noopener">${text}</a>`);
|
|
51
102
|
});
|
|
103
|
+
// bare paths in running text (the common case: the agent just typed a path)
|
|
104
|
+
s = s.replace(FILE_PATH_RE, (m) => (looksLikeLocalPath(m) ? keep(fileLinkHtml(m, m, false)) : m));
|
|
52
105
|
// bold then italic (bold uses ** so must run before single *)
|
|
53
106
|
s = s.replace(/\*\*([^*]+)\*\*/g, (_m, c) => `<strong>${c}</strong>`);
|
|
54
107
|
s = s.replace(/\*([^*]+)\*/g, (_m, c) => `<em>${c}</em>`);
|
|
108
|
+
// restore the stashed HTML — loop so a placeholder nested inside another
|
|
109
|
+
// stashed fragment (e.g. a code span inside a link's text) is also resolved.
|
|
110
|
+
let guard = 0;
|
|
111
|
+
while (s.indexOf('\0') !== -1 && guard++ < 6) {
|
|
112
|
+
s = s.replace(/\0(\d+)\0/g, (_m, i) => (stash[Number(i)] !== undefined ? stash[Number(i)] : ''));
|
|
113
|
+
}
|
|
55
114
|
return s;
|
|
56
115
|
}
|
|
57
116
|
|
|
@@ -220,64 +279,78 @@
|
|
|
220
279
|
return root;
|
|
221
280
|
}
|
|
222
281
|
|
|
223
|
-
// ---------- code tinter (lightweight
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
282
|
+
// ---------- code tinter (lightweight, zero-dependency, offline) ----------
|
|
283
|
+
// Per language we record its comment style(s), whether it has backtick
|
|
284
|
+
// strings, and its keyword set. From those a single alternation regex tints
|
|
285
|
+
// comments / strings / numbers / keywords in one pass. Unknown languages
|
|
286
|
+
// render as plain escaped text; css / html / xml get bespoke patterns.
|
|
287
|
+
// Mirrors the wide-compat goal: no library, works in every browser offline.
|
|
288
|
+
const LANGS = {
|
|
289
|
+
js: { c: true, tmpl: true, kw: 'await async break case catch class const continue debugger default delete do else export extends false finally for from function get if import in instanceof let new null of return set static super switch this throw true try typeof undefined var void while yield' },
|
|
290
|
+
ts: { c: true, tmpl: true, kw: 'abstract any as asserts async await boolean break case catch class const continue declare default delete do else enum export extends false finally for from function get if implements import in infer instanceof interface is keyof let namespace never new null number object of private protected public readonly return satisfies set static string super switch this throw true try type typeof undefined unknown var void while yield' },
|
|
291
|
+
json: { kw: 'true false null' },
|
|
292
|
+
py: { hash: true, kw: 'and as assert async await break case class continue def del elif else except False finally for from global if import in is lambda match None nonlocal not or pass raise return self True try while with yield' },
|
|
293
|
+
sh: { hash: true, kw: 'if then else elif fi for while until do done case esac function in return export local readonly echo cd set unset source eval exec trap exit' },
|
|
294
|
+
go: { c: true, tmpl: true, kw: 'break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false iota' },
|
|
295
|
+
rust: { c: true, kw: 'as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while' },
|
|
296
|
+
java: { c: true, kw: 'abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new null package private protected public return short static super switch synchronized this throw throws transient true false try void volatile while var record sealed' },
|
|
297
|
+
c: { c: true, kw: 'auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while bool true false NULL' },
|
|
298
|
+
cpp: { c: true, kw: 'alignas alignof auto bool break case catch char class const constexpr continue decltype default delete do double else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept nullptr operator private protected public register return short signed sizeof static struct switch template this throw true try typedef typename union unsigned using virtual void volatile while' },
|
|
299
|
+
csharp: { c: true, kw: 'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using var virtual void volatile while async await yield' },
|
|
300
|
+
ruby: { hash: true, kw: 'alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true unless until when while yield require attr_accessor' },
|
|
301
|
+
php: { c: true, hash: true, kw: 'abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum extends false final finally fn for foreach function global goto if implements include instanceof insteadof interface isset list match namespace new null or print private protected public readonly return static switch throw trait true try unset use var while xor yield' },
|
|
302
|
+
swift: { c: true, kw: 'associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private protocol public rethrows static struct subscript typealias var break case continue default defer do else fallthrough for guard if in repeat return switch where while as catch false is nil super self throw throws true try' },
|
|
303
|
+
kotlin: { c: true, kw: 'abstract actual annotation as break by catch class companion const constructor continue crossinline data delegate do dynamic else enum external false final finally for fun get if import in infix init inline inner interface internal is lateinit lazy null object open operator out override package private protected public reified return sealed set super suspend this throw true try typealias val var vararg when where while' },
|
|
304
|
+
yaml: { hash: true, kw: 'true false null yes no on off' },
|
|
305
|
+
toml: { hash: true, kw: 'true false' },
|
|
306
|
+
sql: { c: true, sql: true, kwi: true, kw: 'select from where insert into values update set delete create table alter drop index view join inner left right outer full on as and or not null is in like between group by order having limit offset union all distinct count sum avg min max case when then else end primary key foreign references default unique constraint cascade asc desc returning with' },
|
|
307
|
+
css: { special: 'css' },
|
|
308
|
+
html: { special: 'html' },
|
|
309
|
+
};
|
|
310
|
+
const LANG_ALIAS = {
|
|
311
|
+
javascript: 'js', node: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
|
|
312
|
+
typescript: 'ts', tsx: 'ts',
|
|
313
|
+
shell: 'sh', bash: 'sh', zsh: 'sh', console: 'sh',
|
|
314
|
+
python: 'py', py3: 'py',
|
|
315
|
+
golang: 'go', rs: 'rust', 'c++': 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', 'c#': 'csharp', cs: 'csharp',
|
|
316
|
+
rb: 'ruby', kt: 'kotlin',
|
|
317
|
+
yml: 'yaml', xml: 'html', svg: 'html', htm: 'html',
|
|
318
|
+
jsonc: 'json', json5: 'json',
|
|
232
319
|
};
|
|
233
320
|
|
|
234
321
|
function tintCode(code, lang) {
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
'g'
|
|
255
|
-
);
|
|
256
|
-
classes = ['com', 'str', 'kw', 'num'];
|
|
257
|
-
} else if (key === 'html') {
|
|
258
|
-
rx = new RegExp(
|
|
259
|
-
/(<!--[\s\S]*?-->)/.source +
|
|
260
|
-
'|' + /(<\/?[A-Za-z][\w-]*|\/?>)/.source +
|
|
261
|
-
'|' + /("[^"]*")/.source,
|
|
262
|
-
'g'
|
|
263
|
-
);
|
|
264
|
-
classes = ['com', 'kw', 'str'];
|
|
322
|
+
const key0 = (lang || '').toLowerCase().trim();
|
|
323
|
+
const cfg = LANGS[LANG_ALIAS[key0] || key0];
|
|
324
|
+
if (!cfg) return esc(code); // unknown lang -> plain
|
|
325
|
+
|
|
326
|
+
// Build ONE alternation regex with a parallel classes[] so group g (1-based)
|
|
327
|
+
// maps to classes[g-1]. Each pushed source is wrapped in exactly one
|
|
328
|
+
// capturing group; the source bodies use only non-capturing groups.
|
|
329
|
+
const parts = [];
|
|
330
|
+
const classes = [];
|
|
331
|
+
const push = (src, cls) => { parts.push('(' + src + ')'); classes.push(cls); };
|
|
332
|
+
if (cfg.special === 'css') {
|
|
333
|
+
push(/\/\*[\s\S]*?\*\//.source, 'com');
|
|
334
|
+
push(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/.source, 'str');
|
|
335
|
+
push(/[A-Za-z-]+(?=\s*:)/.source, 'kw');
|
|
336
|
+
push(/\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b/.source, 'num');
|
|
337
|
+
} else if (cfg.special === 'html') {
|
|
338
|
+
push(/<!--[\s\S]*?-->/.source, 'com');
|
|
339
|
+
push(/<\/?[A-Za-z][\w-]*|\/?>/.source, 'kw');
|
|
340
|
+
push(/"[^"]*"/.source, 'str');
|
|
265
341
|
} else {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
? /
|
|
271
|
-
: /
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
'|(\\b(?:' + kwAlt + ')\\b)',
|
|
277
|
-
'g'
|
|
278
|
-
);
|
|
279
|
-
classes = ['com', 'str', 'num', 'kw'];
|
|
342
|
+
if (cfg.c) { push(/\/\*[\s\S]*?\*\//.source, 'com'); push(/\/\/[^\n]*/.source, 'com'); }
|
|
343
|
+
if (cfg.hash) push(/#[^\n]*/.source, 'com');
|
|
344
|
+
if (cfg.sql) push(/--[^\n]*/.source, 'com');
|
|
345
|
+
push((cfg.tmpl
|
|
346
|
+
? /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/
|
|
347
|
+
: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/).source, 'str');
|
|
348
|
+
push(/\b(?:0x[\da-fA-F]+|\d+(?:\.\d+)?)\b/.source, 'num');
|
|
349
|
+
let kws = (cfg.kw || '').split(' ').filter(Boolean);
|
|
350
|
+
if (cfg.kwi) kws = kws.concat(kws.map((k) => k.toUpperCase())); // SQL: also UPPERCASE
|
|
351
|
+
if (kws.length) push('\\b(?:' + kws.join('|') + ')\\b', 'kw');
|
|
280
352
|
}
|
|
353
|
+
const rx = new RegExp(parts.join('|'), 'g');
|
|
281
354
|
let out = '';
|
|
282
355
|
let last = 0;
|
|
283
356
|
let m;
|
|
@@ -298,11 +371,34 @@
|
|
|
298
371
|
return out;
|
|
299
372
|
}
|
|
300
373
|
|
|
374
|
+
// A line-number gutter element for `text` (aria-hidden + non-selectable so a
|
|
375
|
+
// select-to-comment grabs only the code, not the numbers). `startAt` default 1.
|
|
376
|
+
function lineGutter(text, startAt) {
|
|
377
|
+
const n = Math.max(1, String(text).replace(/\n+$/, '').split('\n').length);
|
|
378
|
+
const g = el('div', { class: 'blk-gutter', 'aria-hidden': 'true' });
|
|
379
|
+
let s = '';
|
|
380
|
+
for (let i = 0; i < n; i++) s += (startAt || 1) + i + '\n';
|
|
381
|
+
g.textContent = s;
|
|
382
|
+
return g;
|
|
383
|
+
}
|
|
384
|
+
|
|
301
385
|
function renderCode(block, ctx, blockId) {
|
|
386
|
+
// Drop a single trailing newline so the last source line isn't rendered as
|
|
387
|
+
// an empty row the gutter has no number for (files usually end in "\n").
|
|
388
|
+
const raw = (block.code || '').replace(/\n$/, '');
|
|
302
389
|
const code = el('code');
|
|
303
|
-
code.innerHTML = tintCode(
|
|
390
|
+
code.innerHTML = tintCode(raw, block.lang);
|
|
304
391
|
const pre = el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
|
|
305
|
-
const
|
|
392
|
+
const row = el('div', { class: 'blk-coderow' }, lineGutter(raw), pre);
|
|
393
|
+
const wrap = el('div', { class: 'blk-codewrap' });
|
|
394
|
+
// Optional file-name + language header above the code.
|
|
395
|
+
if (block.filename || block.lang) {
|
|
396
|
+
wrap.append(el('div', { class: 'blk-codehead' },
|
|
397
|
+
el('span', { class: 'blk-codename' }, block.filename || ''),
|
|
398
|
+
el('span', { class: 'blk-codelang' }, block.lang || '')
|
|
399
|
+
));
|
|
400
|
+
}
|
|
401
|
+
wrap.append(row);
|
|
306
402
|
// select-to-comment on the code text (like markdown), plus a whole-block
|
|
307
403
|
// comment + full-screen via the shared viewer toolbar
|
|
308
404
|
ctx && ctx.annotate && ctx.annotate.enableTextSelection(pre, { blockId, questionId: ctx.questionId });
|
|
@@ -310,6 +406,189 @@
|
|
|
310
406
|
return wrap;
|
|
311
407
|
}
|
|
312
408
|
|
|
409
|
+
// ---------- diff (unified / git diff) ----------
|
|
410
|
+
// No git is involved — the agent supplies the diff text. Parsed once into
|
|
411
|
+
// rows, then rendered either UNIFIED (one column, +/- signs) or SPLIT (old |
|
|
412
|
+
// new side-by-side). A header toggle flips between the two live; the block's
|
|
413
|
+
// "view" sets the initial mode. Each code line is tinted by lang.
|
|
414
|
+
|
|
415
|
+
// Parse a unified diff into a flat row list: {kind, text, oldNo, newNo}
|
|
416
|
+
// (line numbers stored 0-based; rendered +1).
|
|
417
|
+
function parseDiff(text) {
|
|
418
|
+
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
|
|
419
|
+
const rows = [];
|
|
420
|
+
let oldNo = 0;
|
|
421
|
+
let newNo = 0;
|
|
422
|
+
for (const line of lines) {
|
|
423
|
+
if (/^@@/.test(line)) {
|
|
424
|
+
const m = line.match(/@@\s*-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/);
|
|
425
|
+
if (m) { oldNo = Number(m[1]) - 1; newNo = Number(m[2]) - 1; }
|
|
426
|
+
rows.push({ kind: 'hunk', text: line });
|
|
427
|
+
} else if (/^(diff |index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |copy )/.test(line)) {
|
|
428
|
+
rows.push({ kind: 'meta', text: line });
|
|
429
|
+
} else if (line[0] === '+') {
|
|
430
|
+
rows.push({ kind: 'add', text: line.slice(1), newNo: newNo++ });
|
|
431
|
+
} else if (line[0] === '-') {
|
|
432
|
+
rows.push({ kind: 'del', text: line.slice(1), oldNo: oldNo++ });
|
|
433
|
+
} else {
|
|
434
|
+
rows.push({ kind: 'ctx', text: line[0] === ' ' ? line.slice(1) : line, oldNo: oldNo++, newNo: newNo++ });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return rows;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function diffNoTd(n, extraClass) {
|
|
441
|
+
const td = el('td', { class: 'diff-no' + (extraClass ? ' ' + extraClass : ''), 'aria-hidden': 'true' });
|
|
442
|
+
if (n !== undefined && n !== null) td.textContent = String(n + 1);
|
|
443
|
+
return td;
|
|
444
|
+
}
|
|
445
|
+
function diffCodeTd(html, extraClass) {
|
|
446
|
+
const c = el('code');
|
|
447
|
+
c.innerHTML = html || ' ';
|
|
448
|
+
return el('td', { class: 'diff-code' + (extraClass ? ' ' + extraClass : '') }, c);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Unified: oldNo | newNo | sign | code
|
|
452
|
+
function buildUnifiedDiff(rows, lang) {
|
|
453
|
+
const tbody = el('tbody');
|
|
454
|
+
for (const r of rows) {
|
|
455
|
+
const full = r.kind === 'hunk' || r.kind === 'meta';
|
|
456
|
+
const codeHtml = full ? esc(r.text) : tintCode(r.text, lang);
|
|
457
|
+
const sign = r.kind === 'add' ? '+' : r.kind === 'del' ? '−' : '';
|
|
458
|
+
tbody.append(el('tr', { class: 'diff-row diff-' + r.kind },
|
|
459
|
+
diffNoTd(r.oldNo),
|
|
460
|
+
diffNoTd(r.newNo),
|
|
461
|
+
el('td', { class: 'diff-sign', 'aria-hidden': 'true' }, sign),
|
|
462
|
+
diffCodeTd(codeHtml)
|
|
463
|
+
));
|
|
464
|
+
}
|
|
465
|
+
return el('table', { class: 'blk-difftable' }, tbody);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Split: oldNo | old code || newNo | new code. A run of removed lines is
|
|
469
|
+
// paired row-by-row with the following run of added lines; the shorter side
|
|
470
|
+
// gets filler cells. Context shows identically on both sides.
|
|
471
|
+
function buildSplitDiff(rows, lang) {
|
|
472
|
+
const tbody = el('tbody');
|
|
473
|
+
const fullRow = (cls, text) => {
|
|
474
|
+
const c = el('code');
|
|
475
|
+
c.innerHTML = esc(text) || ' ';
|
|
476
|
+
tbody.append(el('tr', { class: 'diff-row diff-' + cls }, el('td', { class: 'diff-code', colspan: '4' }, c)));
|
|
477
|
+
};
|
|
478
|
+
let dels = [];
|
|
479
|
+
let adds = [];
|
|
480
|
+
const flush = () => {
|
|
481
|
+
const n = Math.max(dels.length, adds.length);
|
|
482
|
+
for (let i = 0; i < n; i++) {
|
|
483
|
+
const d = dels[i];
|
|
484
|
+
const a = adds[i];
|
|
485
|
+
const left = d
|
|
486
|
+
? [diffNoTd(d.oldNo, 'diff-del'), diffCodeTd(tintCode(d.text, lang), 'diff-del')]
|
|
487
|
+
: [diffNoTd(null, 'diff-fill'), diffCodeTd(' ', 'diff-fill')];
|
|
488
|
+
const right = a
|
|
489
|
+
? [diffNoTd(a.newNo, 'diff-newside diff-add'), diffCodeTd(tintCode(a.text, lang), 'diff-add')]
|
|
490
|
+
: [diffNoTd(null, 'diff-fill diff-newside'), diffCodeTd(' ', 'diff-fill')];
|
|
491
|
+
tbody.append(el('tr', { class: 'diff-row' }, left[0], left[1], right[0], right[1]));
|
|
492
|
+
}
|
|
493
|
+
dels = [];
|
|
494
|
+
adds = [];
|
|
495
|
+
};
|
|
496
|
+
for (const r of rows) {
|
|
497
|
+
if (r.kind === 'del') { dels.push(r); continue; }
|
|
498
|
+
if (r.kind === 'add') { adds.push(r); continue; }
|
|
499
|
+
flush();
|
|
500
|
+
if (r.kind === 'hunk') fullRow('hunk', r.text);
|
|
501
|
+
else if (r.kind === 'meta') fullRow('meta', r.text);
|
|
502
|
+
else {
|
|
503
|
+
const html = tintCode(r.text, lang);
|
|
504
|
+
tbody.append(el('tr', { class: 'diff-row diff-ctx' },
|
|
505
|
+
diffNoTd(r.oldNo), diffCodeTd(html),
|
|
506
|
+
diffNoTd(r.newNo, 'diff-newside'), diffCodeTd(html)
|
|
507
|
+
));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
flush();
|
|
511
|
+
return el('table', { class: 'blk-difftable blk-difftable-split' }, tbody);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function renderDiff(block, ctx, blockId) {
|
|
515
|
+
const rows = parseDiff(block.diff);
|
|
516
|
+
let view = block.view === 'split' ? 'split' : 'unified';
|
|
517
|
+
const scroll = el('div', { class: 'blk-diffscroll' });
|
|
518
|
+
const paint = () => {
|
|
519
|
+
scroll.innerHTML = '';
|
|
520
|
+
scroll.append(view === 'split' ? buildSplitDiff(rows, block.lang) : buildUnifiedDiff(rows, block.lang));
|
|
521
|
+
};
|
|
522
|
+
paint();
|
|
523
|
+
|
|
524
|
+
const wrap = el('div', { class: 'blk-codewrap blk-diffwrap' });
|
|
525
|
+
// header: file name (left) + a Unified/Split view toggle (right)
|
|
526
|
+
const toggle = el('button', { class: 'blk-difftoggle', type: 'button', title: 'Toggle side-by-side view' });
|
|
527
|
+
const syncToggle = () => { toggle.textContent = view === 'split' ? 'Unified view' : 'Split view'; };
|
|
528
|
+
syncToggle();
|
|
529
|
+
toggle.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
530
|
+
toggle.addEventListener('click', (e) => {
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
e.stopPropagation();
|
|
533
|
+
view = view === 'split' ? 'unified' : 'split';
|
|
534
|
+
wrap.classList.toggle('is-split', view === 'split');
|
|
535
|
+
syncToggle();
|
|
536
|
+
paint();
|
|
537
|
+
});
|
|
538
|
+
wrap.append(el('div', { class: 'blk-codehead' },
|
|
539
|
+
el('span', { class: 'blk-codename' }, block.filename || ''),
|
|
540
|
+
toggle
|
|
541
|
+
));
|
|
542
|
+
wrap.classList.toggle('is-split', view === 'split');
|
|
543
|
+
wrap.append(scroll);
|
|
544
|
+
// select-to-comment stays bound to the stable scroll container across toggles
|
|
545
|
+
ctx && ctx.annotate && ctx.annotate.enableTextSelection(scroll, { blockId, questionId: ctx.questionId });
|
|
546
|
+
attachViewer(wrap, { zoomEl: null, label: 'diff', comment: wholeBlockComment(ctx, blockId, 'diff') });
|
|
547
|
+
return wrap;
|
|
548
|
+
}
|
|
549
|
+
// ---------- video (YouTube/Vimeo embed, local stream, or direct URL) ----------
|
|
550
|
+
function renderVideo(block, ctx, blockId) {
|
|
551
|
+
const wrap = el('div', { class: 'blk-videowrap' });
|
|
552
|
+
let media;
|
|
553
|
+
if (block.provider === 'youtube') {
|
|
554
|
+
const q = block.start ? '?start=' + block.start : '';
|
|
555
|
+
media = el('iframe', {
|
|
556
|
+
class: 'blk-video-embed',
|
|
557
|
+
src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(block.videoId) + q,
|
|
558
|
+
title: block.title || 'YouTube video',
|
|
559
|
+
frameborder: '0',
|
|
560
|
+
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
|
|
561
|
+
referrerpolicy: 'strict-origin-when-cross-origin',
|
|
562
|
+
allowfullscreen: 'true',
|
|
563
|
+
loading: 'lazy',
|
|
564
|
+
});
|
|
565
|
+
} else if (block.provider === 'vimeo') {
|
|
566
|
+
media = el('iframe', {
|
|
567
|
+
class: 'blk-video-embed',
|
|
568
|
+
src: 'https://player.vimeo.com/video/' + encodeURIComponent(block.videoId),
|
|
569
|
+
title: block.title || 'Vimeo video',
|
|
570
|
+
frameborder: '0',
|
|
571
|
+
allow: 'autoplay; fullscreen; picture-in-picture',
|
|
572
|
+
allowfullscreen: 'true',
|
|
573
|
+
loading: 'lazy',
|
|
574
|
+
});
|
|
575
|
+
} else {
|
|
576
|
+
// local file (served via /video/b/<id>) or a direct http(s) media URL
|
|
577
|
+
const src = block.hasFile ? '/video/b/' + encodeURIComponent(blockId) : block.src;
|
|
578
|
+
media = el('video', { class: 'blk-video', controls: 'true', preload: 'metadata', playsinline: 'true' });
|
|
579
|
+
if (block.title) media.setAttribute('title', block.title);
|
|
580
|
+
const source = el('source', { src });
|
|
581
|
+
if (block.mime) source.setAttribute('type', block.mime);
|
|
582
|
+
media.append(source);
|
|
583
|
+
media.append(document.createTextNode('Your browser cannot play this video.'));
|
|
584
|
+
}
|
|
585
|
+
wrap.append(media);
|
|
586
|
+
if (block.title) wrap.append(el('div', { class: 'blk-videocap' }, block.title));
|
|
587
|
+
// whole-block comment + the comment pin (no zoom/full-screen for media)
|
|
588
|
+
attachViewer(wrap, { zoomEl: null, label: 'video', comment: wholeBlockComment(ctx, blockId, 'video') });
|
|
589
|
+
return wrap;
|
|
590
|
+
}
|
|
591
|
+
|
|
313
592
|
// ---------- table ----------
|
|
314
593
|
function normalizeColumns(columns) {
|
|
315
594
|
return (columns || []).map((c, idx) => {
|
|
@@ -1429,6 +1708,14 @@
|
|
|
1429
1708
|
inner = renderCode(block, ctx, blockId);
|
|
1430
1709
|
wrapper.append(inner);
|
|
1431
1710
|
break;
|
|
1711
|
+
case 'diff':
|
|
1712
|
+
inner = renderDiff(block, ctx, blockId);
|
|
1713
|
+
wrapper.append(inner);
|
|
1714
|
+
break;
|
|
1715
|
+
case 'video':
|
|
1716
|
+
inner = renderVideo(block, ctx, blockId);
|
|
1717
|
+
wrapper.append(inner);
|
|
1718
|
+
break;
|
|
1432
1719
|
case 'chart':
|
|
1433
1720
|
inner = renderChart(block, ctx, blockId);
|
|
1434
1721
|
wrapper.append(inner);
|
|
@@ -1479,5 +1766,65 @@
|
|
|
1479
1766
|
}
|
|
1480
1767
|
}
|
|
1481
1768
|
|
|
1482
|
-
|
|
1769
|
+
// ---------- file-link open behavior ----------
|
|
1770
|
+
// A small toast pinned top-center (reuses the .toast style from style.css).
|
|
1771
|
+
// tone 'err' adds .toast-err so failures read as a problem, not a success.
|
|
1772
|
+
function fileToast(message, tone) {
|
|
1773
|
+
const t = el('div', { class: 'toast' + (tone === 'err' ? ' toast-err' : '') }, message);
|
|
1774
|
+
document.body.append(t);
|
|
1775
|
+
setTimeout(() => t.remove(), 3500);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// POST the clicked path to /api/open; the server opens it in the OS default
|
|
1779
|
+
// app. The link is disabled mid-flight so a double-click can't double-open.
|
|
1780
|
+
async function openFilePath(a) {
|
|
1781
|
+
const p = a.dataset.rlyOpen;
|
|
1782
|
+
if (!p || a.classList.contains('is-opening')) return;
|
|
1783
|
+
a.classList.add('is-opening');
|
|
1784
|
+
try {
|
|
1785
|
+
const r = await fetch('/api/open', {
|
|
1786
|
+
method: 'POST',
|
|
1787
|
+
headers: { 'content-type': 'application/json' },
|
|
1788
|
+
body: JSON.stringify({ path: p }),
|
|
1789
|
+
});
|
|
1790
|
+
let data = {};
|
|
1791
|
+
try {
|
|
1792
|
+
data = await r.json();
|
|
1793
|
+
} catch {
|
|
1794
|
+
data = {};
|
|
1795
|
+
}
|
|
1796
|
+
if (r.ok && data.ok) fileToast('Opened ' + (data.name || p));
|
|
1797
|
+
else fileToast((data.error || 'Could not open') + ' — ' + p, 'err');
|
|
1798
|
+
} catch {
|
|
1799
|
+
fileToast('Could not reach the server to open ' + p, 'err');
|
|
1800
|
+
} finally {
|
|
1801
|
+
a.classList.remove('is-opening');
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// Delegated once on the document so it catches file-links anywhere they are
|
|
1806
|
+
// rendered — the intro, any markdown block, or a markdown pipe-table cell.
|
|
1807
|
+
// Keyboard-activatable (Enter/Space) since the links are role="link" anchors
|
|
1808
|
+
// with no href (the open happens via fetch, not navigation).
|
|
1809
|
+
function initFileLinks() {
|
|
1810
|
+
if (window.__relayFileLinksReady) return;
|
|
1811
|
+
window.__relayFileLinksReady = true;
|
|
1812
|
+
const hit = (e) => (e.target.closest ? e.target.closest('a.rly-filelink') : null);
|
|
1813
|
+
document.addEventListener('click', (e) => {
|
|
1814
|
+
const a = hit(e);
|
|
1815
|
+
if (!a) return;
|
|
1816
|
+
e.preventDefault();
|
|
1817
|
+
openFilePath(a);
|
|
1818
|
+
});
|
|
1819
|
+
document.addEventListener('keydown', (e) => {
|
|
1820
|
+
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
|
1821
|
+
const a = hit(e);
|
|
1822
|
+
if (!a) return;
|
|
1823
|
+
e.preventDefault();
|
|
1824
|
+
openFilePath(a);
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
initFileLinks();
|
|
1828
|
+
|
|
1829
|
+
window.RelayBlocks = { render, onThemeChange, renderMarkdown };
|
|
1483
1830
|
})();
|
package/src/ui/style.css
CHANGED
|
@@ -87,7 +87,12 @@ h1 {
|
|
|
87
87
|
line-height: 1.25;
|
|
88
88
|
text-wrap: balance;
|
|
89
89
|
}
|
|
90
|
-
.intro { color: var(--fg-2);
|
|
90
|
+
.intro { color: var(--fg-2); margin: 10px 0 18px; }
|
|
91
|
+
/* the intro renders markdown; keep it muted (out-specificity .blk-markdown .md)
|
|
92
|
+
and trim its block margins so it reads as a lead paragraph, not a content block */
|
|
93
|
+
.intro.blk-markdown .md { color: var(--fg-2); }
|
|
94
|
+
.intro.blk-markdown .md > :first-child { margin-top: 0; }
|
|
95
|
+
.intro.blk-markdown .md > :last-child { margin-bottom: 0; }
|
|
91
96
|
.theme-btn {
|
|
92
97
|
background: transparent; color: var(--fg-2);
|
|
93
98
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
|
@@ -262,6 +267,47 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
262
267
|
.scale button { width: 40px; height: 40px; }
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
/* Persistence-lost block: when the board can no longer save the user's input
|
|
271
|
+
(server gone / port taken / machine slept) AND recovery probes have failed,
|
|
272
|
+
we disable every control and overlay this unmissable scrim so the user is
|
|
273
|
+
physically prevented from typing feedback that would be silently discarded.
|
|
274
|
+
Sits above everything (full-screen overlay z 49 / popover / rail / toast). */
|
|
275
|
+
.relay-blocked #app { filter: grayscale(0.4) blur(1px); pointer-events: none; user-select: none; }
|
|
276
|
+
.lost-overlay {
|
|
277
|
+
position: fixed; inset: 0; z-index: 60;
|
|
278
|
+
display: flex; align-items: center; justify-content: center;
|
|
279
|
+
padding: 24px;
|
|
280
|
+
background: color-mix(in srgb, var(--bg) 78%, transparent);
|
|
281
|
+
backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
|
|
282
|
+
}
|
|
283
|
+
.lost-card {
|
|
284
|
+
max-width: 460px; width: 100%;
|
|
285
|
+
background: var(--card); color: var(--fg);
|
|
286
|
+
border: 1px solid var(--border-strong); border-radius: 14px;
|
|
287
|
+
box-shadow: var(--shadow-lift);
|
|
288
|
+
padding: 28px 26px; text-align: center;
|
|
289
|
+
}
|
|
290
|
+
.lost-card .lost-mark {
|
|
291
|
+
width: 52px; height: 52px; border-radius: 50%;
|
|
292
|
+
background: var(--danger); color: var(--danger-fg);
|
|
293
|
+
font-size: 1.6rem; line-height: 52px; margin: 0 auto 16px;
|
|
294
|
+
}
|
|
295
|
+
.lost-card h2 {
|
|
296
|
+
font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em;
|
|
297
|
+
font-size: 1.3rem; margin: 0 0 10px;
|
|
298
|
+
}
|
|
299
|
+
.lost-card p { color: var(--fg-2); font-size: 0.92rem; line-height: 1.55; margin: 0 0 8px; }
|
|
300
|
+
.lost-card .lost-sub { color: var(--muted); font-size: 0.82rem; margin: 12px 0 18px; }
|
|
301
|
+
.lost-card .lost-retry {
|
|
302
|
+
background: var(--accent); color: var(--accent-fg);
|
|
303
|
+
border: none; border-radius: 10px;
|
|
304
|
+
padding: 11px 26px; font: inherit; font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
|
305
|
+
transition: background 150ms var(--ease);
|
|
306
|
+
}
|
|
307
|
+
.lost-card .lost-retry:hover { background: var(--accent-hover); }
|
|
308
|
+
.lost-card .lost-retry:disabled { opacity: 0.6; cursor: default; }
|
|
309
|
+
.lost-card .lost-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
310
|
+
|
|
265
311
|
@media (prefers-reduced-motion: reduce) {
|
|
266
312
|
* { transition: none !important; animation: none !important; }
|
|
267
313
|
}
|