Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

static/js/structured.js

79436f09f5539aac9b6f836b5b9049bbbece48aa · 12.0 KB · 346 lines raw

1 /* Structured-file reading aids for the blob view: fold blocks, search keys
2 and values, filter the file down to what matched.
3
4 Everything here is progressive enhancement. The server renders a plain
5 numbered table with structure hung off data attributes (see
6 internal/modules/structured.go); this file builds the toolbar and the fold
7 controls at runtime, so a page loaded without JS is exactly the table it
8 has always been rather than a dead search box. */
9 (function () {
10 var src = document.querySelector('.source.structured');
11 if (!src) return;
12 var rows = Array.prototype.slice.call(src.querySelectorAll('tbody > tr'));
13 if (!rows.length) return;
14
15 // One record per line. Text is read once from textContent -- never
16 // innerHTML, which would match highlight markup as if it were content.
17 // The alias links and anchor-use badges are chrome the server added, not
18 // file content -- searching "uses" must not match every anchor.
19 function cellText(cell) {
20 if (!cell) return '';
21 var s = '';
22 for (var i = 0; i < cell.childNodes.length; i++) {
23 var c = cell.childNodes[i];
24 if (c.nodeType === 1 && (c.className === 'aref' || c.className === 'anchor-uses')) continue;
25 s += c.textContent;
26 }
27 return s;
28 }
29
30 var R = rows.map(function (tr) {
31 var cell = tr.querySelector('.lc');
32 var text = cellText(cell);
33 var key = tr.dataset.key || '';
34 var val = text;
35 if (key) {
36 var at = text.indexOf(key);
37 var colon = text.indexOf(':', at < 0 ? 0 : at + key.length);
38 if (colon >= 0) val = text.slice(colon + 1);
39 }
40 return {
41 tr: tr, cell: cell,
42 n: +tr.dataset.n,
43 depth: +tr.dataset.depth || 0,
44 foldTo: +tr.dataset.foldTo || 0,
45 key: key.toLowerCase(),
46 path: (tr.dataset.path || '').toLowerCase(),
47 text: text.toLowerCase(),
48 blank: text.trim() === '',
49 val: val.trim().toLowerCase(),
50 match: false
51 };
52 });
53 var byLine = {};
54 R.forEach(function (r) { byLine[r.n] = r; });
55
56 var collapsed = {}; // opener line number -> true
57 var scope = 'all'; // all | keys | values
58 var filtering = false;
59 var matches = [];
60 var cursor = -1;
61
62 /* ---- fold controls ------------------------------------------------ */
63
64 // Minified or tab-indented files have no indentation to fold on. They get
65 // search, but no gutter reserved for chevrons that will never appear and
66 // no collapse-all that would do nothing.
67 var foldable = R.some(function (r) { return r.foldTo > 0; });
68 if (foldable) src.classList.add('foldable');
69
70 R.forEach(function (r) {
71 if (!r.foldTo || !r.cell) return;
72 var b = document.createElement('button');
73 b.className = 'fold';
74 b.type = 'button';
75 b.setAttribute('aria-expanded', 'true');
76 b.title = 'Collapse lines ' + (r.n + 1) + '–' + r.foldTo;
77 b.addEventListener('click', function () {
78 if (collapsed[r.n]) delete collapsed[r.n]; else collapsed[r.n] = true;
79 render();
80 });
81 r.tr.querySelector('.ln').appendChild(b);
82 // On the cell, not the row: attr() in ::after only reads the attributes
83 // of the element the pseudo-element belongs to.
84 r.cell.dataset.foldNote = '⋯ ' + (r.foldTo - r.n) + ' lines';
85 });
86
87 function foldAll(on) {
88 collapsed = {};
89 if (on) R.forEach(function (r) { if (r.foldTo) collapsed[r.n] = true; });
90 render();
91 }
92
93 // A match hidden inside a collapsed block reads as "no results", so open
94 // every block enclosing it before rendering.
95 function expose(n) {
96 for (var o in collapsed) {
97 var r = byLine[o];
98 if (r && r.n < n && n <= r.foldTo) delete collapsed[o];
99 }
100 }
101
102 /* ---- search ------------------------------------------------------- */
103
104 function hay(r) {
105 return scope === 'keys' ? r.key : scope === 'values' ? r.val : r.text;
106 }
107
108 var lastQ = '';
109
110 /* ---- path queries -------------------------------------------------- */
111
112 // A query containing a dot or bracket is tried as a value path first:
113 // obj1.property3.items[4].val. It is normalised to the same canonical
114 // form the server writes into data-path (internal/modules/structured.go),
115 // so a quoted key round-trips and indices attach directly. A query that
116 // resolves to nothing falls through to the substring search below, which
117 // keeps dotted text like a version string searchable.
118 function normalizePath(q) {
119 q = q.trim();
120 if (q[0] === '$') q = q.slice(1);
121 var out = '', first = true, i = 0;
122 function appendKey(k) {
123 if (/^[A-Za-z0-9_$-]+$/.test(k)) out += (first ? '' : '.') + k;
124 else out += '["' + k.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
125 first = false;
126 }
127 while (i < q.length) {
128 var c = q[i];
129 if (c === '.') { i++; continue; }
130 if (c === '[') {
131 var j = i + 1;
132 if (q[j] === '"' || q[j] === "'") {
133 var quote = q[j], key = '';
134 j++;
135 while (j < q.length && q[j] !== quote) {
136 if (q[j] === '\\' && j + 1 < q.length) { key += q[j + 1]; j += 2; }
137 else { key += q[j]; j++; }
138 }
139 if (q[j] !== quote || q[j + 1] !== ']') return '';
140 appendKey(key);
141 i = j + 2;
142 continue;
143 }
144 var num = '';
145 while (j < q.length && q[j] !== ']') { num += q[j]; j++; }
146 if (q[j] !== ']' || !/^\d+$/.test(num)) return '';
147 out += '[' + num + ']';
148 first = false;
149 i = j + 1;
150 continue;
151 }
152 var start = i;
153 while (i < q.length && q[i] !== '.' && q[i] !== '[') i++;
154 appendKey(q.slice(start, i));
155 }
156 return out;
157 }
158
159 // pathMatches returns the set of line numbers the query names, or null
160 // when the query is not a path or names nothing. An exact match also
161 // brings in the subtree beneath it (its items[0], items[0].val, ...), so
162 // naming a container shows its contents. The boundary check keeps a.b
163 // from matching a sibling called a.bc.
164 function pathMatches(q) {
165 var target = normalizePath(q);
166 if (!target) return null;
167 var hit = {}, any = false;
168 R.forEach(function (r) {
169 if (!r.path) return;
170 if (r.path === target) { hit[r.n] = true; any = true; return; }
171 if (r.path.indexOf(target) === 0) {
172 var next = r.path[target.length];
173 if (next === '.' || next === '[') { hit[r.n] = true; any = true; }
174 }
175 });
176 return any ? hit : null;
177 }
178
179 function search(q) {
180 q = lastQ = q.trim().toLowerCase();
181 var hits = q && /[.\[]/.test(q) ? pathMatches(q) : null;
182 matches = [];
183 R.forEach(function (r) {
184 r.match = hits ? !!hits[r.n] : (q !== '' && hay(r).indexOf(q) >= 0);
185 if (r.match) matches.push(r);
186 });
187 matches.forEach(function (r) { expose(r.n); });
188 cursor = matches.length ? 0 : -1;
189 render();
190 }
191
192 // In filter mode a matching line keeps the ancestors that give it its path
193 // and the block it owns -- a bare list of hits out of context is worse
194 // than the file.
195 function keepSet() {
196 var keep = {}, stack = [];
197 R.forEach(function (r) {
198 // A blank line carries no indentation, so it is not a level -- letting
199 // one truncate the stack would strip the parents off every match
200 // below a blank line inside a nested block.
201 if (r.blank) return;
202 stack.length = Math.min(stack.length, r.depth);
203 stack[r.depth] = r;
204 if (!r.match) return;
205 stack.forEach(function (a) { if (a) keep[a.n] = true; });
206 for (var i = r.n; i <= r.foldTo; i++) keep[i] = true;
207 });
208 return keep;
209 }
210
211 /* ---- render ------------------------------------------------------- */
212
213 function render() {
214 if (filtering && !matches.length) {
215 filtering = false;
216 filterBtn.setAttribute('aria-pressed', 'false');
217 }
218 var keep = filtering ? keepSet() : null;
219 var hideUntil = 0;
220 R.forEach(function (r) {
221 var hidden = r.n <= hideUntil || (keep !== null && !keep[r.n]);
222 if (collapsed[r.n]) hideUntil = Math.max(hideUntil, r.foldTo);
223 r.tr.classList.toggle('is-hidden', hidden);
224 r.tr.classList.toggle('is-collapsed', !!collapsed[r.n]);
225 r.tr.classList.toggle('is-match', r.match);
226 r.tr.classList.remove('is-current');
227 var b = r.tr.querySelector('.fold');
228 if (b) b.setAttribute('aria-expanded', collapsed[r.n] ? 'false' : 'true');
229 });
230 if (cursor >= 0 && matches[cursor]) matches[cursor].tr.classList.add('is-current');
231 count.textContent = matches.length
232 ? (cursor + 1) + ' / ' + matches.length
233 : (input.value.trim() ? 'no matches' : '');
234 filterBtn.disabled = !matches.length;
235 }
236
237 function step(d) {
238 if (!matches.length) return;
239 cursor = (cursor + d + matches.length) % matches.length;
240 render();
241 matches[cursor].tr.scrollIntoView({ block: 'center' });
242 }
243
244 /* ---- toolbar ------------------------------------------------------ */
245
246 var bar = document.createElement('div');
247 bar.className = 'sbar';
248
249 var input = document.createElement('input');
250 input.type = 'search';
251 input.className = 'sbar-q';
252 input.placeholder = 'Search keys, values, or a path like a.b[0]… (/)';
253 input.title = 'Substring search, or a path such as obj.items[0].key';
254 input.setAttribute('aria-label', 'Search this file');
255
256 var scopes = document.createElement('div');
257 scopes.className = 'sbar-scope';
258 ['all', 'keys', 'values'].forEach(function (s) {
259 var b = document.createElement('button');
260 b.type = 'button';
261 b.textContent = s;
262 b.setAttribute('aria-pressed', String(s === scope));
263 b.addEventListener('click', function () {
264 scope = s;
265 Array.prototype.forEach.call(scopes.children, function (c) {
266 c.setAttribute('aria-pressed', String(c === b));
267 });
268 search(input.value);
269 });
270 scopes.appendChild(b);
271 });
272
273 var count = document.createElement('span');
274 count.className = 'sbar-count';
275
276 function chip(label, title, fn) {
277 var b = document.createElement('button');
278 b.type = 'button';
279 b.className = 'sbar-btn';
280 b.textContent = label;
281 b.title = title;
282 b.addEventListener('click', fn);
283 return b;
284 }
285
286 var prev = chip('↑', 'Previous match (Shift+Enter)', function () { step(-1); });
287 var next = chip('↓', 'Next match (Enter)', function () { step(1); });
288
289 var filterBtn = chip('filter', 'Show only matching lines and their parents', function () {
290 filtering = !filtering;
291 filterBtn.setAttribute('aria-pressed', String(filtering));
292 render();
293 });
294 filterBtn.setAttribute('aria-pressed', 'false');
295 filterBtn.disabled = true;
296
297 var allCollapsed = false;
298 var foldBtn = chip('collapse all', 'Fold every block', function () {
299 allCollapsed = !allCollapsed;
300 foldBtn.textContent = allCollapsed ? 'expand all' : 'collapse all';
301 foldAll(allCollapsed);
302 });
303
304 bar.appendChild(input);
305 bar.appendChild(scopes);
306 bar.appendChild(count);
307 bar.appendChild(prev);
308 bar.appendChild(next);
309 bar.appendChild(filterBtn);
310 if (foldable) bar.appendChild(foldBtn);
311 src.parentNode.insertBefore(bar, src);
312
313 var timer;
314 input.addEventListener('input', function () {
315 clearTimeout(timer);
316 timer = setTimeout(function () { search(input.value); }, 90);
317 });
318 input.addEventListener('keydown', function (e) {
319 if (e.key === 'Enter') {
320 e.preventDefault();
321 clearTimeout(timer);
322 // Enter on a query the debounce hasn't run yet searches; Enter on the
323 // same query walks the results.
324 if (input.value.trim().toLowerCase() !== lastQ) search(input.value);
325 else step(e.shiftKey ? -1 : 1);
326 }
327 else if (e.key === 'Escape') { input.value = ''; search(''); input.blur(); }
328 });
329 document.addEventListener('keydown', function (e) {
330 if (e.key !== '/' || e.target === input || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
331 e.preventDefault();
332 input.focus();
333 input.select();
334 });
335
336 // Deep links (#L42, and the alias jump links) must survive folding.
337 window.addEventListener('hashchange', function () {
338 var m = /^#L(\d+)$/.exec(location.hash);
339 if (!m) return;
340 expose(+m[1]);
341 if (filtering) { filtering = false; filterBtn.setAttribute('aria-pressed', 'false'); }
342 render();
343 });
344
345 render();
346 })();