feat(#1187): dynamic terminal scrollback with paginated history
This commit is contained in:
parent
fcd170e8b8
commit
62841a549d
4 changed files with 227 additions and 26 deletions
|
|
@ -152,6 +152,33 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
.tail-pill:hover { filter: brightness(1.1); }
|
||||
/* "↑ load older" pill: sits inline at the top of the log (not
|
||||
absolutely positioned) so it scrolls with the content. Appears
|
||||
when `has_more` is true after initial history load. */
|
||||
.load-more-pill {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.08em;
|
||||
border: 0;
|
||||
border-bottom: 1px dashed var(--purple-dim);
|
||||
padding: 0.4em 1em;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.load-more-pill:hover:not(:disabled) {
|
||||
color: var(--fg);
|
||||
background: var(--border);
|
||||
}
|
||||
.load-more-pill:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
|
||||
negative text-indent) so the disclosure glyph (`▸ / ▾`) lands in
|
||||
exactly the same column as flat-row prefix glyphs (`→ ← · ◆ ✓ ✗`).
|
||||
|
|
|
|||
|
|
@ -57,8 +57,12 @@ const SCROLL_ANIM_MS = 140;
|
|||
const SCROLL_SNAP_PX = 24;
|
||||
|
||||
export function create(opts) {
|
||||
const log = opts.logEl;
|
||||
// `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)));
|
||||
|
|
@ -68,6 +72,11 @@ export function create(opts) {
|
|||
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
|
||||
|
|
@ -257,6 +266,80 @@ export function create(opts) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
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.
|
||||
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
|
||||
}
|
||||
updateLoadMoreBtn();
|
||||
} 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.
|
||||
|
|
@ -359,11 +442,16 @@ export function create(opts) {
|
|||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
// Accept the envelope `{ seq, events }`. A bare array means
|
||||
// the server hasn't been updated to include seq yet — treat
|
||||
// it as "no dedupe possible."
|
||||
// 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));
|
||||
|
|
@ -373,6 +461,8 @@ export function create(opts) {
|
|||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue