web: clickable links in terminal rows and dashboard messages (issue #233)
This commit is contained in:
parent
4b51c198d5
commit
15e44955a8
5 changed files with 82 additions and 8 deletions
|
|
@ -18,7 +18,11 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed
|
||||||
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
||||||
history backfill, SSE plumbing all live there. Each page
|
history backfill, SSE plumbing all live there. Each page
|
||||||
registers a kind→renderer map; unknown kinds fall through to
|
registers a kind→renderer map; unknown kinds fall through to
|
||||||
a JSON-dump note row.
|
a JSON-dump note row. Bare `http(s)://` URLs in row text are
|
||||||
|
turned into clickable new-tab links by `HiveTerminal.linkify`
|
||||||
|
(text-node based, no `innerHTML` — XSS-safe); markdown bodies
|
||||||
|
get the same treatment via `marked`'s autolink, with the
|
||||||
|
rendered `<a>`s rewritten to `target="_blank"` (issue #233).
|
||||||
- `GET /api/state` → JSON snapshot the JS app renders into the
|
- `GET /api/state` → JSON snapshot the JS app renders into the
|
||||||
DOM. Includes a top-level `seq` (the dashboard event channel's
|
DOM. Includes a top-level `seq` (the dashboard event channel's
|
||||||
high-water mark at the moment the snapshot was assembled);
|
high-water mark at the moment the snapshot was assembled);
|
||||||
|
|
|
||||||
|
|
@ -755,6 +755,12 @@
|
||||||
try {
|
try {
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
div.innerHTML = marked.parse(src);
|
div.innerHTML = marked.parse(src);
|
||||||
|
// marked autolinks URLs but leaves them same-tab — open them
|
||||||
|
// externally so a click never unloads the terminal. (issue #233)
|
||||||
|
div.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('marked failed', err);
|
console.warn('marked failed', err);
|
||||||
div.textContent = src;
|
div.textContent = src;
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,12 @@
|
||||||
if (window.marked && typeof window.marked.parse === 'function') {
|
if (window.marked && typeof window.marked.parse === 'function') {
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
div.innerHTML = marked.parse(text);
|
div.innerHTML = marked.parse(text);
|
||||||
|
// marked autolinks URLs but leaves them same-tab — open externally
|
||||||
|
// so a click never navigates away from the dashboard. (issue #233)
|
||||||
|
div.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
@ -192,12 +198,23 @@
|
||||||
// not in `refs` stays plain text. No client-side regex, no probe
|
// not in `refs` stays plain text. No client-side regex, no probe
|
||||||
// endpoint — the server saw the body first and made the call. When
|
// endpoint — the server saw the body first and made the call. When
|
||||||
// `refs` is empty/missing we just emit plain text.
|
// `refs` is empty/missing we just emit plain text.
|
||||||
|
// Append a plain-text run, with bare http(s) URLs turned into clickable
|
||||||
|
// links via the shared terminal linkifier. Falls back to a plain text
|
||||||
|
// node if the terminal module hasn't loaded. (issue #233)
|
||||||
|
function appendText(parent, s) {
|
||||||
|
if (!s) return;
|
||||||
|
if (window.HiveTerminal && typeof HiveTerminal.linkify === 'function') {
|
||||||
|
parent.appendChild(HiveTerminal.linkify(s));
|
||||||
|
} else {
|
||||||
|
parent.appendChild(document.createTextNode(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
function appendLinkified(parent, text, refs) {
|
function appendLinkified(parent, text, refs) {
|
||||||
if (text == null) return;
|
if (text == null) return;
|
||||||
const str = String(text);
|
const str = String(text);
|
||||||
const tokens = (refs || []).slice();
|
const tokens = (refs || []).slice();
|
||||||
if (!tokens.length) {
|
if (!tokens.length) {
|
||||||
if (str) parent.appendChild(document.createTextNode(str));
|
appendText(parent, str);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Walk the string left-to-right, at each step looking for the
|
// Walk the string left-to-right, at each step looking for the
|
||||||
|
|
@ -220,11 +237,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bestStart === -1) {
|
if (bestStart === -1) {
|
||||||
parent.appendChild(document.createTextNode(str.slice(i)));
|
appendText(parent, str.slice(i));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (bestStart > i) {
|
if (bestStart > i) {
|
||||||
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
|
appendText(parent, str.slice(i, bestStart));
|
||||||
}
|
}
|
||||||
parent.appendChild(makePathLink(bestToken));
|
parent.appendChild(makePathLink(bestToken));
|
||||||
i = bestStart + bestToken.length;
|
i = bestStart + bestToken.length;
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,9 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.live .row .md a { color: var(--cyan); text-decoration: underline; }
|
.live .row .md a { color: var(--cyan); text-decoration: underline; }
|
||||||
|
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
|
||||||
|
.live .row a { color: var(--cyan); text-decoration: underline; }
|
||||||
|
.live .row a:hover { color: var(--fg); }
|
||||||
.live .row .md strong { color: inherit; font-weight: bold; }
|
.live .row .md strong { color: inherit; font-weight: bold; }
|
||||||
.live .row .md em { color: inherit; font-style: italic; }
|
.live .row .md em { color: inherit; font-style: italic; }
|
||||||
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
|
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
clearPlaceholder();
|
clearPlaceholder();
|
||||||
const e = document.createElement('div');
|
const e = document.createElement('div');
|
||||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
e.textContent = text;
|
e.appendChild(linkify(text));
|
||||||
log.appendChild(e);
|
log.appendChild(e);
|
||||||
afterAppend();
|
afterAppend();
|
||||||
return e;
|
return e;
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
d.appendChild(s);
|
d.appendChild(s);
|
||||||
const pre = document.createElement('pre');
|
const pre = document.createElement('pre');
|
||||||
pre.className = 'tool-body';
|
pre.className = 'tool-body';
|
||||||
pre.textContent = body;
|
pre.appendChild(linkify(body));
|
||||||
d.appendChild(pre);
|
d.appendChild(pre);
|
||||||
log.appendChild(d);
|
log.appendChild(d);
|
||||||
afterAppend();
|
afterAppend();
|
||||||
|
|
@ -158,7 +158,7 @@
|
||||||
|
|
||||||
function api(extra) {
|
function api(extra) {
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
row, details, detailsDiff, placeholder,
|
row, details, detailsDiff, placeholder, linkify,
|
||||||
fromHistory: false,
|
fromHistory: false,
|
||||||
}, extra || {});
|
}, extra || {});
|
||||||
}
|
}
|
||||||
|
|
@ -296,5 +296,49 @@
|
||||||
return { row, details, detailsDiff, placeholder, ready };
|
return { row, details, detailsDiff, placeholder, ready };
|
||||||
}
|
}
|
||||||
|
|
||||||
window.HiveTerminal = { create };
|
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
|
||||||
|
// clickable links that open in a new tab. Non-URL text stays as plain
|
||||||
|
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
|
||||||
|
// punctuation is kept out of the link. (issue #233)
|
||||||
|
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||||
|
function linkify(text) {
|
||||||
|
const str = text == null ? '' : String(text);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (str.indexOf('://') === -1) { // fast path: no URLs
|
||||||
|
if (str) frag.appendChild(document.createTextNode(str));
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
let last = 0;
|
||||||
|
let m;
|
||||||
|
LINKIFY_URL_RE.lastIndex = 0;
|
||||||
|
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
|
||||||
|
let url = m[0];
|
||||||
|
// Don't swallow trailing punctuation that's really sentence text.
|
||||||
|
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
||||||
|
const tail = trail ? trail[0] : '';
|
||||||
|
if (tail) url = url.slice(0, -tail.length);
|
||||||
|
if (m.index > last) {
|
||||||
|
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
|
||||||
|
}
|
||||||
|
if (!url.slice(url.indexOf('://') + 3)) {
|
||||||
|
// Nothing past the scheme — not a real URL, emit verbatim.
|
||||||
|
frag.appendChild(document.createTextNode(m[0]));
|
||||||
|
} else {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; // regex only matches https?:// — safe
|
||||||
|
a.textContent = url;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
frag.appendChild(a);
|
||||||
|
if (tail) frag.appendChild(document.createTextNode(tail));
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < str.length) {
|
||||||
|
frag.appendChild(document.createTextNode(str.slice(last)));
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.HiveTerminal = { create, linkify };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue