From 4d0d98c604c5c82fc7dd38e2d1dfcf7602ebcff2 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Wed, 6 May 2026 19:27:59 +0000 Subject: [PATCH] feat: smart fold around errors with drag-to-reveal handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the all-or-nothing "Errors only" collapse with a contextual fold: ±25 entries around every error stay visible, and runs of non-error entries with no nearby error collapse into a fold bar. Each fold bar is draggable. Vertical pointer drag on the bar reveals or re-hides lines from the top of the hidden range, ~6 px per line. A click without drag reveals the next 25 lines. When the run is fully revealed, the bar removes itself. Buttons / scroll behaviour are unchanged; the existing "X errors" toggle in the header is the entry point and still un-collapses everything on second click. Visual: foldable bars get a faint horizontal hatch (suggesting compressed content), an ns-resize cursor, and a hover/dragging state that intensifies the hatch toward --accent. The grip-lines icon flanks the line-count to call out the affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/public/css/iblogs.css | 65 +++++++++++++ web/public/js/log.js | 187 ++++++++++++++++++++++++++------------ 2 files changed, 194 insertions(+), 58 deletions(-) diff --git a/web/public/css/iblogs.css b/web/public/css/iblogs.css index 97d3386..62f2204 100644 --- a/web/public/css/iblogs.css +++ b/web/public/css/iblogs.css @@ -1088,6 +1088,71 @@ main { color: var(--accent); } +/* + * Foldable variant — generated by the smart "errors + 25 context" toggle in + * log.js. The bar is draggable: vertical pointer drag reveals or re-hides + * lines from the top of the hidden range. Visual cues: + * - ns-resize cursor announces the drag direction. + * - Faint horizontal hatch hints at compressed content underneath. + * - Hover and drag states intensify the hatch toward --accent so the + * bar reads as an interactive separator, not decorative chrome. + */ +.collapsed-lines-foldable { + cursor: ns-resize; + user-select: none; + -webkit-user-select: none; +} + +.collapsed-lines-foldable .collapsed-lines-count { + background: + repeating-linear-gradient( + to bottom, + transparent 0, + transparent 3px, + color-mix(in srgb, var(--border) 60%, transparent) 3px, + color-mix(in srgb, var(--border) 60%, transparent) 4px + ), + var(--surface); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + transition: + background-color 0.12s ease, + color 0.12s ease, + border-color 0.12s ease, + box-shadow 0.12s ease; +} + +.collapsed-lines-foldable:hover .collapsed-lines-count, +.collapsed-lines-dragging .collapsed-lines-count { + background: + repeating-linear-gradient( + to bottom, + transparent 0, + transparent 3px, + color-mix(in srgb, var(--accent) 35%, transparent) 3px, + color-mix(in srgb, var(--accent) 35%, transparent) 4px + ), + var(--accent-bg); + border-top-color: color-mix(in srgb, var(--accent) 60%, transparent); + border-bottom-color: color-mix(in srgb, var(--accent) 60%, transparent); + color: var(--accent); +} + +.collapsed-lines-foldable:hover .collapsed-lines-count i, +.collapsed-lines-dragging .collapsed-lines-count i { + color: var(--accent); +} + +.collapsed-lines-dragging { + cursor: grabbing; +} + +.collapsed-lines-dragging .collapsed-lines-count { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent), + 0 6px 16px color-mix(in srgb, var(--accent) 25%, transparent); +} + .log-inner .level { display: block; white-space: pre-wrap; diff --git a/web/public/js/log.js b/web/public/js/log.js index 4ff295a..a8c9019 100644 --- a/web/public/js/log.js +++ b/web/public/js/log.js @@ -47,7 +47,20 @@ function scrollToHeight(top, smoothScrollLimit = 10000) { window.scrollTo({left: 0, top, behavior}); } -/* error collapse toggle */ +/* + * Error collapse toggle. + * + * Smart fold: every entry within ±ERROR_WINDOW_SIZE of an error stays + * visible; runs of non-error entries with no nearby errors collapse into + * a draggable fold bar. Vertical drag on the bar progressively reveals or + * re-hides lines; a click without drag reveals the next DEFAULT_CLICK_REVEAL + * lines. + */ +const ERROR_WINDOW_SIZE = 25; +const PIXELS_PER_LINE_DRAG = 6; +const DEFAULT_CLICK_REVEAL = 25; +const DRAG_PIXEL_THRESHOLD = 3; + const toggleErrorsButton = document.getElementById("error-toggle"); if (toggleErrorsButton) { toggleErrorsButton.addEventListener("click", toggleErrors); @@ -64,76 +77,134 @@ function toggleErrors() { } function collapseAllErrors() { - let firstNoErrorLine = false; - let lines = document.querySelectorAll('.log-inner > .entry'); - let totalLines = lines.length; - for (const [i, line] of lines.entries()) { - let lineNumber = line.querySelector(".line-number").innerHTML; - if (line.classList.contains("entry-no-error")) { - line.style.display = "none"; + const lines = Array.from(document.querySelectorAll('.log-inner > .entry')); + const total = lines.length; + if (!total) return; - if (firstNoErrorLine === false) { - firstNoErrorLine = lineNumber; - } - - if (i + 1 === totalLines && firstNoErrorLine) { - line.insertAdjacentElement("afterend", generateCollapsedLines(firstNoErrorLine, lineNumber)); - } - } else { - if (firstNoErrorLine) { - line.insertAdjacentElement("beforebegin", generateCollapsedLines(firstNoErrorLine, lineNumber - 1)); - firstNoErrorLine = false; - } + // Pass 1: mark entries within ±ERROR_WINDOW_SIZE of any error. + const mustShow = new Array(total).fill(false); + for (let i = 0; i < total; i++) { + if (lines[i].classList.contains("entry-error")) { + const lo = Math.max(0, i - ERROR_WINDOW_SIZE); + const hi = Math.min(total - 1, i + ERROR_WINDOW_SIZE); + for (let j = lo; j <= hi; j++) mustShow[j] = true; } } + + // Pass 2: hide unmarked entries; emit a fold bar at the START of each run. + let runEntries = []; + const flushRun = () => { + if (!runEntries.length) return; + const bar = createFoldBar(runEntries); + runEntries[0].insertAdjacentElement("beforebegin", bar); + runEntries = []; + }; + for (let i = 0; i < total; i++) { + if (!mustShow[i]) { + lines[i].style.display = "none"; + runEntries.push(lines[i]); + } else { + flushRun(); + } + } + flushRun(); } function uncollapseAllErrors() { - document.querySelectorAll('.entry-no-error').forEach(line => line.style.removeProperty("display")); - document.querySelectorAll('.collapsed-lines').forEach(collapsed => collapsed.remove()); + document.querySelectorAll(".log-inner > .entry").forEach(line => line.style.removeProperty("display")); + document.querySelectorAll(".collapsed-lines").forEach(bar => bar.remove()); } -function handleCollapsedClick(e) { - let collapsed = e.currentTarget; - let positionElement = document.getElementById(`L${parseInt(collapsed.dataset.end) + 1}`); - let position; - if (positionElement) { - position = positionElement.getBoundingClientRect().top - window.scrollY; - } - for (let i = parseInt(collapsed.dataset.start); i <= parseInt(collapsed.dataset.end); i++) { - document.getElementById(`L${i}`).parentElement.parentElement.style.removeProperty("display"); - } - if (positionElement) { - window.scrollTo({ - left: 0, - top: positionElement.getBoundingClientRect().top - position - collapsed.offsetHeight, - behavior: "instant" - }); - } - collapsed.remove(); +function createFoldBar(entries) { + const bar = document.createElement("div"); + bar.classList.add("collapsed-lines", "collapsed-lines-foldable"); + bar.appendChild(document.createElement("div")); // line-number column slot + + const count = document.createElement("div"); + count.classList.add("collapsed-lines-count"); + bar.appendChild(count); + + bar._hiddenEntries = entries; + bar._revealedCount = 0; + renderFoldLabel(bar); + attachFoldDragHandler(bar); + return bar; } -function generateCollapsedLines(start, end) { - let count = end - start + 1; - let string = count === 1 ? "line" : "lines"; +function renderFoldLabel(bar) { + const entries = bar._hiddenEntries; + const total = entries.length; + const revealed = bar._revealedCount; + const remaining = total - revealed; + const count = bar.querySelector(".collapsed-lines-count"); + count.replaceChildren(); - let collapsedRow = document.createElement("div"); - collapsedRow.classList.add("collapsed-lines"); - collapsedRow.dataset.start = start; - collapsedRow.dataset.end = end; - collapsedRow.appendChild(document.createElement("div")); - collapsedRow.addEventListener("click", handleCollapsedClick); + if (remaining <= 0) return; - let collapsedLinesCount = document.createElement("div"); - collapsedLinesCount.classList.add("collapsed-lines-count"); - let icon = document.createElement("i"); - icon.classList.add("fa-solid", "fa-angle-up"); - collapsedLinesCount.appendChild(icon); - collapsedLinesCount.append(` ${count} ${string} `); - collapsedLinesCount.append(icon.cloneNode()); - collapsedRow.appendChild(collapsedLinesCount); + const firstHiddenLine = parseInt(entries[revealed].querySelector(".line-number").innerHTML, 10); + const lastHiddenLine = parseInt(entries[total - 1].querySelector(".line-number").innerHTML, 10); + const word = remaining === 1 ? "line" : "lines"; - return collapsedRow; + const grip = document.createElement("i"); + grip.className = "fa-solid fa-grip-lines"; + count.appendChild(grip); + count.append(` ${remaining} ${word} hidden · ${firstHiddenLine}–${lastHiddenLine} · drag to reveal `); + count.appendChild(grip.cloneNode()); +} + +function applyFoldReveal(bar, n) { + const entries = bar._hiddenEntries; + n = Math.max(0, Math.min(entries.length, n)); + if (n === bar._revealedCount) return; + for (let i = 0; i < entries.length; i++) { + if (i < n) entries[i].style.removeProperty("display"); + else entries[i].style.display = "none"; + } + bar._revealedCount = n; + renderFoldLabel(bar); +} + +function attachFoldDragHandler(bar) { + let dragging = false; + let dragStartY = 0; + let dragStartReveal = 0; + let didDrag = false; + + bar.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + dragging = true; + didDrag = false; + dragStartY = e.clientY; + dragStartReveal = bar._revealedCount; + bar.setPointerCapture(e.pointerId); + bar.classList.add("collapsed-lines-dragging"); + e.preventDefault(); + }); + + bar.addEventListener("pointermove", (e) => { + if (!dragging) return; + const dy = e.clientY - dragStartY; + if (Math.abs(dy) >= DRAG_PIXEL_THRESHOLD) didDrag = true; + const target = dragStartReveal + Math.round(dy / PIXELS_PER_LINE_DRAG); + applyFoldReveal(bar, target); + }); + + const finish = (e) => { + if (!dragging) return; + dragging = false; + try { bar.releasePointerCapture(e.pointerId); } catch {} + bar.classList.remove("collapsed-lines-dragging"); + + if (!didDrag) { + // Plain click: reveal the next chunk of lines. + applyFoldReveal(bar, bar._revealedCount + DEFAULT_CLICK_REVEAL); + } + if (bar._revealedCount >= bar._hiddenEntries.length) { + bar.remove(); + } + }; + bar.addEventListener("pointerup", finish); + bar.addEventListener("pointercancel", finish); } /* convert timestamps */