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,
|
||||
history backfill, SSE plumbing all live there. Each page
|
||||
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
|
||||
DOM. Includes a top-level `seq` (the dashboard event channel's
|
||||
high-water mark at the moment the snapshot was assembled);
|
||||
|
|
|
|||
|
|
@ -755,6 +755,12 @@
|
|||
try {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
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) {
|
||||
console.warn('marked failed', err);
|
||||
div.textContent = src;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,12 @@
|
|||
if (window.marked && typeof window.marked.parse === 'function') {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
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 {
|
||||
div.textContent = text;
|
||||
}
|
||||
|
|
@ -192,12 +198,23 @@
|
|||
// not in `refs` stays plain text. No client-side regex, no probe
|
||||
// endpoint — the server saw the body first and made the call. When
|
||||
// `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) {
|
||||
if (text == null) return;
|
||||
const str = String(text);
|
||||
const tokens = (refs || []).slice();
|
||||
if (!tokens.length) {
|
||||
if (str) parent.appendChild(document.createTextNode(str));
|
||||
appendText(parent, str);
|
||||
return;
|
||||
}
|
||||
// Walk the string left-to-right, at each step looking for the
|
||||
|
|
@ -220,11 +237,11 @@
|
|||
}
|
||||
}
|
||||
if (bestStart === -1) {
|
||||
parent.appendChild(document.createTextNode(str.slice(i)));
|
||||
appendText(parent, str.slice(i));
|
||||
break;
|
||||
}
|
||||
if (bestStart > i) {
|
||||
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
|
||||
appendText(parent, str.slice(i, bestStart));
|
||||
}
|
||||
parent.appendChild(makePathLink(bestToken));
|
||||
i = bestStart + bestToken.length;
|
||||
|
|
|
|||
|
|
@ -213,6 +213,9 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
|||
border-radius: 0;
|
||||
}
|
||||
.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 em { color: inherit; font-style: italic; }
|
||||
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@
|
|||
clearPlaceholder();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
e.textContent = text;
|
||||
e.appendChild(linkify(text));
|
||||
log.appendChild(e);
|
||||
afterAppend();
|
||||
return e;
|
||||
|
|
@ -127,7 +127,7 @@
|
|||
d.appendChild(s);
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body';
|
||||
pre.textContent = body;
|
||||
pre.appendChild(linkify(body));
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
afterAppend();
|
||||
|
|
@ -158,7 +158,7 @@
|
|||
|
||||
function api(extra) {
|
||||
return Object.assign({
|
||||
row, details, detailsDiff, placeholder,
|
||||
row, details, detailsDiff, placeholder, linkify,
|
||||
fromHistory: false,
|
||||
}, extra || {});
|
||||
}
|
||||
|
|
@ -296,5 +296,49 @@
|
|||
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