/* Structured-file reading aids for the blob view: fold blocks, search keys and values, filter the file down to what matched. Everything here is progressive enhancement. The server renders a plain numbered table with structure hung off data attributes (see internal/modules/structured.go); this file builds the toolbar and the fold controls at runtime, so a page loaded without JS is exactly the table it has always been rather than a dead search box. */ (function () { var src = document.querySelector('.source.structured'); if (!src) return; var rows = Array.prototype.slice.call(src.querySelectorAll('tbody > tr')); if (!rows.length) return; // One record per line. Text is read once from textContent -- never // innerHTML, which would match highlight markup as if it were content. // The alias links and anchor-use badges are chrome the server added, not // file content -- searching "uses" must not match every anchor. function cellText(cell) { if (!cell) return ''; var s = ''; for (var i = 0; i < cell.childNodes.length; i++) { var c = cell.childNodes[i]; if (c.nodeType === 1 && (c.className === 'aref' || c.className === 'anchor-uses')) continue; s += c.textContent; } return s; } var R = rows.map(function (tr) { var cell = tr.querySelector('.lc'); var text = cellText(cell); var key = tr.dataset.key || ''; var val = text; if (key) { var at = text.indexOf(key); var colon = text.indexOf(':', at < 0 ? 0 : at + key.length); if (colon >= 0) val = text.slice(colon + 1); } return { tr: tr, cell: cell, n: +tr.dataset.n, depth: +tr.dataset.depth || 0, foldTo: +tr.dataset.foldTo || 0, key: key.toLowerCase(), text: text.toLowerCase(), blank: text.trim() === '', val: val.trim().toLowerCase(), match: false }; }); var byLine = {}; R.forEach(function (r) { byLine[r.n] = r; }); var collapsed = {}; // opener line number -> true var scope = 'all'; // all | keys | values var filtering = false; var matches = []; var cursor = -1; /* ---- fold controls ------------------------------------------------ */ // Minified or tab-indented files have no indentation to fold on. They get // search, but no gutter reserved for chevrons that will never appear and // no collapse-all that would do nothing. var foldable = R.some(function (r) { return r.foldTo > 0; }); if (foldable) src.classList.add('foldable'); R.forEach(function (r) { if (!r.foldTo || !r.cell) return; var b = document.createElement('button'); b.className = 'fold'; b.type = 'button'; b.setAttribute('aria-expanded', 'true'); b.title = 'Collapse lines ' + (r.n + 1) + '–' + r.foldTo; b.addEventListener('click', function () { if (collapsed[r.n]) delete collapsed[r.n]; else collapsed[r.n] = true; render(); }); r.tr.querySelector('.ln').appendChild(b); // On the cell, not the row: attr() in ::after only reads the attributes // of the element the pseudo-element belongs to. r.cell.dataset.foldNote = '⋯ ' + (r.foldTo - r.n) + ' lines'; }); function foldAll(on) { collapsed = {}; if (on) R.forEach(function (r) { if (r.foldTo) collapsed[r.n] = true; }); render(); } // A match hidden inside a collapsed block reads as "no results", so open // every block enclosing it before rendering. function expose(n) { for (var o in collapsed) { var r = byLine[o]; if (r && r.n < n && n <= r.foldTo) delete collapsed[o]; } } /* ---- search ------------------------------------------------------- */ function hay(r) { return scope === 'keys' ? r.key : scope === 'values' ? r.val : r.text; } var lastQ = ''; function search(q) { q = lastQ = q.trim().toLowerCase(); matches = []; R.forEach(function (r) { r.match = q !== '' && hay(r).indexOf(q) >= 0; if (r.match) matches.push(r); }); matches.forEach(function (r) { expose(r.n); }); cursor = matches.length ? 0 : -1; render(); } // In filter mode a matching line keeps the ancestors that give it its path // and the block it owns -- a bare list of hits out of context is worse // than the file. function keepSet() { var keep = {}, stack = []; R.forEach(function (r) { // A blank line carries no indentation, so it is not a level -- letting // one truncate the stack would strip the parents off every match // below a blank line inside a nested block. if (r.blank) return; stack.length = Math.min(stack.length, r.depth); stack[r.depth] = r; if (!r.match) return; stack.forEach(function (a) { if (a) keep[a.n] = true; }); for (var i = r.n; i <= r.foldTo; i++) keep[i] = true; }); return keep; } /* ---- render ------------------------------------------------------- */ function render() { if (filtering && !matches.length) { filtering = false; filterBtn.setAttribute('aria-pressed', 'false'); } var keep = filtering ? keepSet() : null; var hideUntil = 0; R.forEach(function (r) { var hidden = r.n <= hideUntil || (keep !== null && !keep[r.n]); if (collapsed[r.n]) hideUntil = Math.max(hideUntil, r.foldTo); r.tr.classList.toggle('is-hidden', hidden); r.tr.classList.toggle('is-collapsed', !!collapsed[r.n]); r.tr.classList.toggle('is-match', r.match); r.tr.classList.remove('is-current'); var b = r.tr.querySelector('.fold'); if (b) b.setAttribute('aria-expanded', collapsed[r.n] ? 'false' : 'true'); }); if (cursor >= 0 && matches[cursor]) matches[cursor].tr.classList.add('is-current'); count.textContent = matches.length ? (cursor + 1) + ' / ' + matches.length : (input.value.trim() ? 'no matches' : ''); filterBtn.disabled = !matches.length; } function step(d) { if (!matches.length) return; cursor = (cursor + d + matches.length) % matches.length; render(); matches[cursor].tr.scrollIntoView({ block: 'center' }); } /* ---- toolbar ------------------------------------------------------ */ var bar = document.createElement('div'); bar.className = 'sbar'; var input = document.createElement('input'); input.type = 'search'; input.className = 'sbar-q'; input.placeholder = 'Search keys and values… (/)'; input.setAttribute('aria-label', 'Search this file'); var scopes = document.createElement('div'); scopes.className = 'sbar-scope'; ['all', 'keys', 'values'].forEach(function (s) { var b = document.createElement('button'); b.type = 'button'; b.textContent = s; b.setAttribute('aria-pressed', String(s === scope)); b.addEventListener('click', function () { scope = s; Array.prototype.forEach.call(scopes.children, function (c) { c.setAttribute('aria-pressed', String(c === b)); }); search(input.value); }); scopes.appendChild(b); }); var count = document.createElement('span'); count.className = 'sbar-count'; function chip(label, title, fn) { var b = document.createElement('button'); b.type = 'button'; b.className = 'sbar-btn'; b.textContent = label; b.title = title; b.addEventListener('click', fn); return b; } var prev = chip('↑', 'Previous match (Shift+Enter)', function () { step(-1); }); var next = chip('↓', 'Next match (Enter)', function () { step(1); }); var filterBtn = chip('filter', 'Show only matching lines and their parents', function () { filtering = !filtering; filterBtn.setAttribute('aria-pressed', String(filtering)); render(); }); filterBtn.setAttribute('aria-pressed', 'false'); filterBtn.disabled = true; var allCollapsed = false; var foldBtn = chip('collapse all', 'Fold every block', function () { allCollapsed = !allCollapsed; foldBtn.textContent = allCollapsed ? 'expand all' : 'collapse all'; foldAll(allCollapsed); }); bar.appendChild(input); bar.appendChild(scopes); bar.appendChild(count); bar.appendChild(prev); bar.appendChild(next); bar.appendChild(filterBtn); if (foldable) bar.appendChild(foldBtn); src.parentNode.insertBefore(bar, src); var timer; input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { search(input.value); }, 90); }); input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(timer); // Enter on a query the debounce hasn't run yet searches; Enter on the // same query walks the results. if (input.value.trim().toLowerCase() !== lastQ) search(input.value); else step(e.shiftKey ? -1 : 1); } else if (e.key === 'Escape') { input.value = ''; search(''); input.blur(); } }); document.addEventListener('keydown', function (e) { if (e.key !== '/' || e.target === input || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; e.preventDefault(); input.focus(); input.select(); }); // Deep links (#L42, and the alias jump links) must survive folding. window.addEventListener('hashchange', function () { var m = /^#L(\d+)$/.exec(location.hash); if (!m) return; expose(+m[1]); if (filtering) { filtering = false; filterBtn.setAttribute('aria-pressed', 'false'); } render(); }); render(); })();