frontend: move shared/terminal.js+css into one-dir terminal/
Same pure structural move as the previous commit, applied to the one other remaining genuine component in shared/src (a self-contained widget with its own behaviour + CSS, same class as hive-btn/hive- dialog/hive-toast/hive-menu/side-panel/tabs) -- not the CSS-foundation files (colors/theme/base/chrome.css) or the utility modules (forms.js, dom.js, modal.js, shadow-css.js), which aren't components and don't fit the one-dir-per-component convention. External callers resolve terminal.js/terminal.css only through @hive/shared's exports map, so again the two exports targets are the only external-facing change. index.js's own internal re-export uses a relative path within the package, so that needed updating too. Zero call-site changes outside @hive/shared. Verified the built dashboard (flow.js/common.css) and agent (app.js/agent.css) bundles still resolve both files.
This commit is contained in:
parent
fbc09f1b3d
commit
d50bea588a
4 changed files with 3 additions and 3 deletions
|
|
@ -1,550 +0,0 @@
|
|||
// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
|
||||
// backfill + live SSE. Pages provide a kind→renderer map; this module
|
||||
// owns scroll behaviour, animation suppression on backfill, and the
|
||||
// EventSource lifecycle.
|
||||
//
|
||||
// create(opts) — full options list + renderer api (api.row, api.details,
|
||||
// api.detailsDiff, api.placeholder, api.fromHistory) + behavioral notes:
|
||||
// docs/web-ui/shape.md §Shared terminal pane.
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
// Scroll distance from the top of the log that triggers an automatic
|
||||
// "load older" fetch — fires via the scroll event handler so the operator
|
||||
// never has to click the pill; the pill stays as a visual indicator.
|
||||
const LOAD_MORE_SCROLL_PX = 80;
|
||||
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
|
||||
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
|
||||
// 500ms-browser-default + 24px short-circuit rationale.
|
||||
const SCROLL_ANIM_MS = 140;
|
||||
const SCROLL_SNAP_PX = 24;
|
||||
|
||||
export function create(opts) {
|
||||
// `log` is `let` not `const` so loadMore() can temporarily redirect
|
||||
// row/details/etc. into a detached temp element while prepending older
|
||||
// history (restored before any scrollTop adjustments).
|
||||
let log = opts.logEl;
|
||||
if (!log) throw new Error('HiveTerminal.create: logEl is required');
|
||||
const rootLog = log; // always the real DOM element — never reassigned
|
||||
const renderers = opts.renderers || {};
|
||||
const defaultRender = renderers._default
|
||||
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
||||
const pillAnchor = opts.pillAnchor || log.parentElement || log;
|
||||
|
||||
let placeholderEl = null;
|
||||
let pill = null;
|
||||
let unseen = 0;
|
||||
let currentNoAnim = false;
|
||||
// Pagination state for the "load older" feature.
|
||||
let histMinId = null;
|
||||
let histHasMore = false;
|
||||
let histLoading = false;
|
||||
let loadMoreBtn = null;
|
||||
// Sticky-bottom intent. True means "keep snapping to bottom on
|
||||
// any mutation"; false means "the operator scrolled up — leave
|
||||
// them alone". Updated synchronously from the scroll event
|
||||
// handler so both programmatic scrollTop assignments and
|
||||
// operator-driven wheel/drag stay in sync.
|
||||
let stickToBottom = true;
|
||||
// Scroll-handler gate during in-flight snap animations — see
|
||||
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
|
||||
// guard) for the eased-through-not-near-bottom rationale.
|
||||
let smoothScrollingUntil = 0;
|
||||
// rAF id for the current snap animation. Cancelled when a new
|
||||
// snap starts so we never have two animations fighting over
|
||||
// scrollTop.
|
||||
let scrollAnimRaf = 0;
|
||||
|
||||
function isNearBottom() {
|
||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
// Snap the log to the bottom with a brief eased animation. Cancels
|
||||
// any in-flight frame loop so back-to-back snaps coalesce; falls
|
||||
// back to instant scroll during `currentNoAnim` backfill replay
|
||||
// or under SCROLL_SNAP_PX. See docs/web-ui.md::Shared terminal pane.
|
||||
function snapToBottom(immediate) {
|
||||
stickToBottom = true;
|
||||
if (scrollAnimRaf) {
|
||||
cancelAnimationFrame(scrollAnimRaf);
|
||||
scrollAnimRaf = 0;
|
||||
}
|
||||
const target = log.scrollHeight - log.clientHeight;
|
||||
const start = log.scrollTop;
|
||||
const distance = target - start;
|
||||
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
|
||||
smoothScrollingUntil = 0;
|
||||
log.scrollTop = target;
|
||||
return;
|
||||
}
|
||||
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
|
||||
const t0 = performance.now();
|
||||
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
||||
const step = (now) => {
|
||||
const elapsed = now - t0;
|
||||
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
|
||||
// Re-read target each frame so mutations landing mid-animation
|
||||
// (the common case — a renderer appended badge / body bits
|
||||
// after api.row returned) extend the destination smoothly
|
||||
// rather than landing short.
|
||||
const currentTarget = log.scrollHeight - log.clientHeight;
|
||||
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
|
||||
if (frac < 1) {
|
||||
scrollAnimRaf = requestAnimationFrame(step);
|
||||
} else {
|
||||
// Final exact settle on the as-of-now bottom.
|
||||
log.scrollTop = log.scrollHeight - log.clientHeight;
|
||||
scrollAnimRaf = 0;
|
||||
}
|
||||
};
|
||||
scrollAnimRaf = requestAnimationFrame(step);
|
||||
}
|
||||
function ensurePill() {
|
||||
if (pill) return pill;
|
||||
pill = document.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'tail-pill';
|
||||
pill.addEventListener('click', () => snapToBottom());
|
||||
pillAnchor.appendChild(pill);
|
||||
return pill;
|
||||
}
|
||||
function updatePill() {
|
||||
if (unseen <= 0) {
|
||||
if (pill) pill.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
ensurePill();
|
||||
pill.textContent = '↓ ' + unseen + ' new';
|
||||
pill.classList.add('visible');
|
||||
}
|
||||
log.addEventListener('scroll', () => {
|
||||
// Sticky-bottom intent tracking. Outside an animation this is
|
||||
// straightforward — stickToBottom = isNearBottom(). During a
|
||||
// smooth-snap animation we swallow most of the event to avoid a
|
||||
// feedback loop (programmatic scrollTop changes → scroll events →
|
||||
// new snap → cancels current rAF), but we MUST still let the user
|
||||
// break out of sticky mode: if the user scrolls away from the
|
||||
// bottom while a snap animation is in flight, honour that intent
|
||||
// immediately so the MutationObserver stops re-firing snapToBottom()
|
||||
// and the animation-guard window can expire naturally. Without this,
|
||||
// live events arriving < 220ms apart permanently block scroll-to-top
|
||||
// and loadMore() never fires. See docs/web-ui.md::Shared terminal
|
||||
// pane (Mid-animation scroll-event guard).
|
||||
const inAnim = Date.now() < smoothScrollingUntil;
|
||||
const nearBottom = isNearBottom();
|
||||
if (!inAnim) {
|
||||
stickToBottom = nearBottom;
|
||||
} else if (!nearBottom) {
|
||||
// User scrolled up during an animation — break sticky mode so the
|
||||
// MO stops calling snapToBottom() and the gate expires.
|
||||
stickToBottom = false;
|
||||
}
|
||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||
if (inAnim) return;
|
||||
// Auto-fetch older history when the user scrolls near the top — no
|
||||
// click required; the load-more pill stays as a visual indicator.
|
||||
if (rootLog.scrollTop <= LOAD_MORE_SCROLL_PX && histHasMore && !histLoading) {
|
||||
loadMore();
|
||||
}
|
||||
});
|
||||
// Post-append mutation snap — catches renderer mutations that land
|
||||
// after `api.row` returns (badges, multi-line bodies, tool
|
||||
// panes). See docs/web-ui.md::Shared terminal pane (Post-append
|
||||
// MutationObserver) for why the pre-append `afterAppend` hop
|
||||
// alone isn't enough.
|
||||
const mo = new MutationObserver(() => {
|
||||
if (stickToBottom) snapToBottom();
|
||||
});
|
||||
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
// Pre-append nearBottom snapshot drives the initial snap decision —
|
||||
// see docs/web-ui.md::Shared terminal pane (Post-append
|
||||
// MutationObserver) for why we need both this and the MO.
|
||||
function afterAppend(wasNearBottom) {
|
||||
if (currentNoAnim || wasNearBottom) {
|
||||
snapToBottom();
|
||||
} else {
|
||||
unseen += 1;
|
||||
updatePill();
|
||||
}
|
||||
}
|
||||
function clearPlaceholder() {
|
||||
if (placeholderEl && placeholderEl.parentElement === log) {
|
||||
log.removeChild(placeholderEl);
|
||||
}
|
||||
placeholderEl = null;
|
||||
}
|
||||
function placeholder(text) {
|
||||
clearPlaceholder();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row note';
|
||||
e.textContent = text;
|
||||
log.appendChild(e);
|
||||
placeholderEl = e;
|
||||
}
|
||||
// A leading icon (`→ ← 🧠 🖥️ …`) goes in a fixed-width `.row-glyph`
|
||||
// element so every row's icon lands in one column regardless of the
|
||||
// glyph's rendered width (emoji vary; some carry variation selectors).
|
||||
// Optional: callers that pass no `icon` keep the bare first-character
|
||||
// prefix the older rows rely on.
|
||||
function glyphSpan(icon) {
|
||||
const g = document.createElement('span');
|
||||
g.className = 'row-glyph';
|
||||
g.textContent = icon;
|
||||
return g;
|
||||
}
|
||||
// Build a <summary> whose icon (if any) sits in the shared `.row-glyph`
|
||||
// column and whose text lives in a `.summary-text` span — the disclosure
|
||||
// caret (CSS `.summary-text::before`) then leads the text, not the icon,
|
||||
// so the icon stays aligned with flat-row icons.
|
||||
function buildSummary(summary, icon) {
|
||||
const s = document.createElement('summary');
|
||||
if (icon != null && icon !== '') s.appendChild(glyphSpan(icon));
|
||||
const st = document.createElement('span');
|
||||
st.className = 'summary-text';
|
||||
st.textContent = summary;
|
||||
s.appendChild(st);
|
||||
return s;
|
||||
}
|
||||
function row(cls, text, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
|
||||
e.appendChild(linkify(text));
|
||||
log.appendChild(e);
|
||||
afterAppend(wasNearBottom);
|
||||
return e;
|
||||
}
|
||||
// Like row(), but returns [element, textNode] so the caller can update
|
||||
// the text in place via textNode.nodeValue. Use for rows whose content
|
||||
// changes after initial render (e.g. live-updating counters).
|
||||
// Text is stored as a plain text node — no linkify, no innerHTML.
|
||||
function mutableRow(cls, text, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
|
||||
const tn = document.createTextNode(text == null ? '' : String(text));
|
||||
e.appendChild(tn);
|
||||
log.appendChild(e);
|
||||
afterAppend(wasNearBottom);
|
||||
return [e, tn];
|
||||
}
|
||||
function details(cls, summary, body, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
d.appendChild(buildSummary(summary, icon));
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body';
|
||||
pre.appendChild(linkify(body));
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
afterAppend(wasNearBottom);
|
||||
return d;
|
||||
}
|
||||
function detailsDiff(cls, summary, body, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
d.appendChild(buildSummary(summary, icon));
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body diff-body';
|
||||
for (const line of String(body).split('\n')) {
|
||||
const span = document.createElement('span');
|
||||
if (line.startsWith('+ ')) span.className = 'diff-add';
|
||||
else if (line.startsWith('- ')) span.className = 'diff-del';
|
||||
else span.className = 'diff-ctx';
|
||||
span.textContent = line + '\n';
|
||||
pre.appendChild(span);
|
||||
}
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
afterAppend(wasNearBottom);
|
||||
return d;
|
||||
}
|
||||
|
||||
function api(extra) {
|
||||
return Object.assign({
|
||||
row, mutableRow, details, detailsDiff, placeholder, linkify,
|
||||
fromHistory: false,
|
||||
}, extra || {});
|
||||
}
|
||||
function dispatch(ev, fromHistory) {
|
||||
const r = renderers[ev.kind] || defaultRender;
|
||||
try {
|
||||
r(ev, api({ fromHistory }));
|
||||
} catch (err) {
|
||||
console.error('terminal renderer threw', ev, err);
|
||||
row('note', '[render err] ' + (err && err.message ? err.message : err));
|
||||
}
|
||||
if (opts.onAnyEvent) {
|
||||
try { opts.onAnyEvent(ev, { fromHistory }); }
|
||||
catch (err) { console.error('onAnyEvent threw', err); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load-older machinery ───────────────────────────────────────
|
||||
//
|
||||
// When the initial backfill response includes `has_more: true`, a
|
||||
// "↑ load older" button appears at the top of the log. Clicking it
|
||||
// fetches the next page (`?before=<min_id>`) and prepends the events
|
||||
// while holding the viewport steady so the operator's reading position
|
||||
// doesn't jump.
|
||||
|
||||
function updateLoadMoreBtn() {
|
||||
if (!histHasMore || !opts.historyUrl) {
|
||||
if (loadMoreBtn && loadMoreBtn.parentElement) {
|
||||
loadMoreBtn.parentElement.removeChild(loadMoreBtn);
|
||||
}
|
||||
loadMoreBtn = null;
|
||||
return;
|
||||
}
|
||||
if (!loadMoreBtn) {
|
||||
loadMoreBtn = document.createElement('button');
|
||||
loadMoreBtn.type = 'button';
|
||||
loadMoreBtn.className = 'load-more-pill';
|
||||
loadMoreBtn.addEventListener('click', loadMore);
|
||||
rootLog.prepend(loadMoreBtn);
|
||||
}
|
||||
loadMoreBtn.textContent = '↑ load older';
|
||||
loadMoreBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null) return;
|
||||
histLoading = true;
|
||||
if (loadMoreBtn) { loadMoreBtn.textContent = '↑ loading…'; loadMoreBtn.disabled = true; }
|
||||
try {
|
||||
const sep = opts.historyUrl.includes('?') ? '&' : '?';
|
||||
const url = opts.historyUrl + sep + 'before=' + histMinId;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { updateLoadMoreBtn(); return; }
|
||||
const body = await resp.json();
|
||||
const events = Array.isArray(body) ? body : (body.events || []);
|
||||
histHasMore = body.has_more || false;
|
||||
if (typeof body.min_id === 'number') histMinId = body.min_id;
|
||||
|
||||
// Resolve load-more button state before capturing the scroll
|
||||
// baseline so that any button removal is already reflected in
|
||||
// beforeH — otherwise the button's height would be missing from
|
||||
// the delta and the viewport would drift up by that amount.
|
||||
updateLoadMoreBtn();
|
||||
|
||||
if (events.length > 0) {
|
||||
// Render into a detached element; `log` is temporarily redirected
|
||||
// so that row/details/etc. append there instead of rootLog.
|
||||
const tempEl = document.createElement('div');
|
||||
log = tempEl;
|
||||
currentNoAnim = true;
|
||||
for (const ev of events) dispatch(ev, true);
|
||||
currentNoAnim = false;
|
||||
log = rootLog;
|
||||
|
||||
// Separator to mark the boundary between loaded-older and newer.
|
||||
const sepEl = document.createElement('div');
|
||||
sepEl.className = 'row note no-anim';
|
||||
sepEl.textContent = '─── older above ───';
|
||||
tempEl.appendChild(sepEl);
|
||||
|
||||
// Insert before the "live" divider (i.e. right after the load-more
|
||||
// button if present, else at the very top of rootLog).
|
||||
const anchor = loadMoreBtn ? loadMoreBtn.nextSibling : rootLog.firstChild;
|
||||
const beforeH = rootLog.scrollHeight;
|
||||
while (tempEl.firstChild) rootLog.insertBefore(tempEl.firstChild, anchor);
|
||||
// Compensate scroll so the viewport stays on the same content.
|
||||
// overflow-anchor: none on .live ensures the browser does not
|
||||
// also auto-adjust scrollTop (which would double the delta).
|
||||
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('loadMore failed', err);
|
||||
updateLoadMoreBtn();
|
||||
} finally {
|
||||
histLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe → buffer → fetch history → dedupe → apply.
|
||||
//
|
||||
// Race the SSE subscription opens before the history fetch starts.
|
||||
// Live events that land before history resolves are buffered, not
|
||||
// rendered. Once the history response (`{ seq, events }`) arrives we:
|
||||
// 1. Replay `events` (fromHistory=true).
|
||||
// 2. Drop buffered events with `seq <= history.seq` — they're
|
||||
// already reflected in the history rows above.
|
||||
// 3. Apply remaining buffered events (fromHistory=false).
|
||||
// 4. Switch to live mode: each new SSE event dispatches immediately.
|
||||
//
|
||||
// Without this dance an event that fires between history-fetch and
|
||||
// SSE-subscribe goes missing; without seq dedupe the same event
|
||||
// shows twice (once via history, once via live buffer). Both bugs
|
||||
// were latent before.
|
||||
//
|
||||
// If `historyUrl` is unset we skip the dance: buffered events apply
|
||||
// as live the moment the buffer flushes (no dedupe possible without
|
||||
// a boundary seq).
|
||||
function start() {
|
||||
let live = false;
|
||||
let buffered = [];
|
||||
|
||||
// Optional streamFactory(url) → EventSource-shaped facade. Lets
|
||||
// the dashboard hand the factory a SharedWorker-backed source so
|
||||
// open hyperhive tabs share one upstream — see
|
||||
// docs/web-ui.md::Shared terminal pane (Backfill + SSE). Default
|
||||
// falls back to `new EventSource(url)`.
|
||||
const es = opts.streamFactory
|
||||
? opts.streamFactory(opts.streamUrl)
|
||||
: new EventSource(opts.streamUrl);
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); }
|
||||
catch (err) { row('note', '[parse err] ' + e.data); return; }
|
||||
if (!live) { buffered.push(ev); return; }
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// SharedWorker-backed facades expose `readyState` mirroring the
|
||||
// upstream EventSource state; the native EventSource exposes the
|
||||
// same. Either way the CONNECTING vs. closed distinction works.
|
||||
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
|
||||
else row('note', '[disconnected]');
|
||||
};
|
||||
es.onopen = () => {
|
||||
// Fires on the initial connect and on every automatic
|
||||
// reconnect. EventSource never replays events that fired
|
||||
// during a disconnect window, so a consumer with
|
||||
// snapshot-derived state (the dashboard's /api/state stores)
|
||||
// must re-sync here or it shows stale state until a manual
|
||||
// reload.
|
||||
if (opts.onStreamOpen) {
|
||||
try { opts.onStreamOpen(); }
|
||||
catch (err) { console.error('onStreamOpen threw', err); }
|
||||
}
|
||||
};
|
||||
|
||||
function flushBuffered(boundarySeq, historyKinds) {
|
||||
const drained = buffered;
|
||||
buffered = [];
|
||||
live = true;
|
||||
for (const ev of drained) {
|
||||
// Seq-dedupe only events of a kind that actually appeared in
|
||||
// the history replay — those are the only ones that could
|
||||
// double (once via history, once via the live buffer).
|
||||
// Mutation events (approval/question/container/…) are never
|
||||
// carried by the history endpoint; deduping them against the
|
||||
// broker-history seq would wrongly drop ones that fired
|
||||
// between a consumer's own snapshot read and this history
|
||||
// fetch. ev.seq absent/0 → no dedupe possible.
|
||||
if (boundarySeq != null
|
||||
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
||||
&& historyKinds && historyKinds.has(ev.kind)) {
|
||||
continue;
|
||||
}
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
if (!opts.historyUrl) {
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(opts.historyUrl);
|
||||
if (!resp.ok) {
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
// Accept the envelope `{ seq, events, min_id?, has_more? }`.
|
||||
// A bare array means the server hasn't been updated — treat it
|
||||
// as "no dedupe possible, no pagination."
|
||||
const events = Array.isArray(body) ? body : (body.events || []);
|
||||
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
|
||||
// Pagination cursors — set on the outer load-more state.
|
||||
if (!Array.isArray(body)) {
|
||||
histHasMore = body.has_more || false;
|
||||
if (typeof body.min_id === 'number') histMinId = body.min_id;
|
||||
}
|
||||
// Kinds present in the history replay — the only kinds that
|
||||
// can double and therefore the only ones to seq-dedupe.
|
||||
const historyKinds = new Set(events.map((ev) => ev.kind));
|
||||
currentNoAnim = true;
|
||||
for (const ev of events) dispatch(ev, true);
|
||||
currentNoAnim = false;
|
||||
if (events.length) row('note', '─── live (older above) ───');
|
||||
else placeholder('(connected — waiting for events)');
|
||||
flushBuffered(boundarySeq, historyKinds);
|
||||
// Show load-older button if the server reports more history.
|
||||
updateLoadMoreBtn();
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
||||
} catch (err) {
|
||||
console.warn('history backfill failed', err);
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
}
|
||||
}
|
||||
return backfill();
|
||||
}
|
||||
|
||||
const ready = start();
|
||||
return { row, mutableRow, details, detailsDiff, placeholder, ready };
|
||||
}
|
||||
|
||||
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
|
||||
// clickable links that open in a new tab. See docs/web-ui.md::Shared
|
||||
// terminal pane (linkify) for the text-node-only / no-innerHTML
|
||||
// XSS-safety + trailing-punctuation strip.
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
export 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;
|
||||
}
|
||||
Loading…
Reference in a new issue