From 70e308d804e74cda56363445399d514515eda476 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Wed, 12 Aug 2026 19:03:05 -1000 Subject: Match the client age buckets to the server's --- assets/cgit.js | 260 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 156 insertions(+), 104 deletions(-) (limited to 'assets') diff --git a/assets/cgit.js b/assets/cgit.js index f3fedc1..0fed45a 100644 --- a/assets/cgit.js +++ b/assets/cgit.js @@ -1,150 +1,182 @@ -/* cgit.js: javacript functions for cgit - * - * Copyright (C) 2006-2018 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see LICENSE.txt for full license text) +/* + * The client side script that cgit serves with every page. Its main job is + * to refresh the relative ages that cgit_print_age renders in + * source/ui-shared.c, so the thresholds, suffixes and class names tabulated + * below mirror the constants there and the two have to move together. Three + * smaller pieces follow, the selects that reload the page when they change, + * the highlight laid over the source line a URL fragment names, and the + * colour theme toggle. Each piece is wrapped in a function of its own so + * that nothing is left behind in the global scope. */ (function () { -/* This follows the logic and suffixes used in ui-shared.c */ - -var age_classes = [ "age-mins", "age-hours", "age-days", "age-weeks", "age-months", "age-years" ]; -var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years", "years" ]; -var age_next = [ 60, 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 365 * 24 * 3600, 365 * 24 * 3600 ]; -var age_limit = [ 7200, 24 * 7200, 7 * 24 * 7200, 30 * 24 * 7200, 365 * 25 * 7200, 365 * 25 * 7200 ]; -var update_next = [ 10, 5 * 60, 1800, 24 * 3600, 24 * 3600, 24 * 3600, 24 * 3600 ]; - -function render_age(e, age) { - var t, n; - - for (n = 0; n < age_classes.length; n++) - if (age < age_limit[n]) +// Written the way SECONDS_PER_* are derived in source/cgit.h, so that the +// bucket a browser picks is the bucket cgit_print_age already picked on the +// server. A month is a twelfth of a year rather than thirty days, which is +// the definition the server uses. +var MINUTE = 60; +var HOUR = 60 * MINUTE; +var DAY = 24 * HOUR; +var WEEK = 7 * DAY; +var YEAR = 365 * DAY; +var MONTH = YEAR / 12; + +// The five arrays are indexed together by the bucket an age falls into. The +// search in render_age can stop one place past the last class, so the arrays +// it reads there repeat their final entry. Each limit is twice the next unit +// up, matching the thresholds cgit_print_age compares against. +var age_classes = [ "age-mins", "age-hours", "age-days", "age-weeks", "age-months", "age-years" ]; +var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years", "years" ]; +var age_unit = [ MINUTE, HOUR, DAY, WEEK, MONTH, YEAR, YEAR ]; +var age_limit = [ 2 * HOUR, 2 * DAY, 2 * WEEK, 2 * MONTH, 2 * YEAR, 2 * YEAR ]; +var update_delay = [ 10, 5 * MINUTE, 30 * MINUTE, DAY, DAY, DAY, DAY ]; + +function render_age(element, age) { + var text, bucket; + + for (bucket = 0; bucket < age_classes.length; bucket++) + if (age < age_limit[bucket]) break; - t = Math.round(age / age_next[n]) + " " + age_suffix[n]; - - if (e.textContent != t) { - e.textContent = t; - if (n == age_classes.length) - n--; - if (e.className != age_classes[n]) - e.className = age_classes[n]; + text = Math.round(age / age_unit[bucket]) + " " + age_suffix[bucket]; + + // Every age on the page is measured again on each pass, so most of + // them are already reading correctly and writing to them would cost a + // repaint for nothing. + if (element.textContent != text) { + element.textContent = text; + // An age past the last limit is still counted in years, but + // there is no class beyond age-years to put on it. + if (bucket == age_classes.length) + bucket--; + if (element.className != age_classes[bucket]) + element.className = age_classes[bucket]; } } -function aging() { - var n, next = 24 * 3600, - now_ut = Math.round((new Date().getTime() / 1000)); +/* + * Measures every age on the page against the clock and books the next pass. + * There is no point coming back before the coarsest unit on the page could + * change, so a page already counted in hours is left alone for minutes and + * one counted in years for a day. + */ +function refresh_ages() { + var bucket, elements, i, age; + var delay = 24 * 3600; + var now = Math.round(new Date().getTime() / 1000); - for (n = 0; n < age_classes.length; n++) { - var m, elems = document.getElementsByClassName(age_classes[n]); + for (bucket = 0; bucket < age_classes.length; bucket++) { + elements = document.getElementsByClassName(age_classes[bucket]); - if (elems.length && update_next[n] < next) - next = update_next[n]; + if (elements.length && update_delay[bucket] < delay) + delay = update_delay[bucket]; - for (m = 0; m < elems.length; m++) { - var age = now_ut - elems[m].getAttribute("data-ut"); + for (i = 0; i < elements.length; i++) { + age = now - elements[i].getAttribute("data-ut"); - /* A commit dated ahead of the viewer's clock would - * otherwise render as a negative age; ui-shared.c - * clamps the same way. */ + // A commit dated ahead of the viewer's clock would + // otherwise show a negative age, and ui-shared.c + // clamps it the same way. if (age < 0) age = 0; - render_age(elems[m], age); + render_age(elements[i], age); } } - /* - * We only need to come back when the age might have changed. - * Eg, if everything is counted in hours already, once per - * 5 minutes is accurate enough. - */ - - window.setTimeout(aging, next * 1000); + window.setTimeout(refresh_ages, delay * 1000); } -document.addEventListener("DOMContentLoaded", function() { - /* we can do the aging on DOM content load since no layout dependency */ - aging(); +document.addEventListener("DOMContentLoaded", function () { + // Nothing here depends on layout, so the first pass can run as soon + // as the document is parsed. + refresh_ages(); }, false); })(); -/* Selects marked data-autosubmit reload the page with the new setting. - * Wired here instead of inline onchange handlers, which a strict - * Content-Security-Policy blocks. Without scripting the forms keep - * their noscript reload button. */ +/* + * Selects marked data-autosubmit reload the page with the new setting. They + * are wired up from here rather than with an inline onchange handler because + * a strict Content-Security-Policy blocks those, and a reader without + * scripting still has the noscript reload button the forms carry. + */ (function () { document.addEventListener("DOMContentLoaded", function () { - var i, els = document.querySelectorAll("select[data-autosubmit]"); + var i, selects = document.querySelectorAll("select[data-autosubmit]"); - for (i = 0; i < els.length; i++) - els[i].addEventListener("change", function () { + for (i = 0; i < selects.length; i++) + selects[i].addEventListener("change", function () { this.form.submit(); }); }, false); })(); -/* Wash a faded highlight over the source line targeted by the URL - * fragment, #n231, or over a range like #n5-n12. The line anchors live - * in the number gutter, so this measures the anchor and lays a - * full-width bar across the code at the same height. Without JS the - * CSS :target rule still tints the line number itself. */ +/* + * Washes a faded highlight over the source line a URL fragment names, either + * a single line as in #n231 or a range as in #n5-n12. The line anchors live + * in the number gutter, so the anchor is measured and a bar of the same + * height is laid across the full width of the code. Without scripting the + * CSS target rule still tints the line number itself. + */ (function () { var bar = null; -function place() { - var m, a, b, table, box, ra, rb, top, bottom; +function place_bar() { + var match, first, last, table; + var table_box, first_box, last_box, top, bottom; if (bar) { bar.remove(); bar = null; } - m = /^#n(\d+)(?:-n?(\d+))?$/.exec(location.hash); - if (!m) + match = /^#n(\d+)(?:-n?(\d+))?$/.exec(location.hash); + if (!match) return; - a = document.getElementById("n" + m[1]); - if (!a) + first = document.getElementById("n" + match[1]); + if (!first) return; - b = (m[2] && document.getElementById("n" + m[2])) || a; - table = a.closest("table"); + last = (match[2] && document.getElementById("n" + match[2])) || first; + table = first.closest("table"); if (!table) return; - box = table.getBoundingClientRect(); - ra = a.getBoundingClientRect(); - rb = b.getBoundingClientRect(); - top = Math.min(ra.top, rb.top) - box.top; - bottom = Math.max(ra.bottom, rb.bottom) - box.top; + table_box = table.getBoundingClientRect(); + first_box = first.getBoundingClientRect(); + last_box = last.getBoundingClientRect(); + top = Math.min(first_box.top, last_box.top) - table_box.top; + bottom = Math.max(first_box.bottom, last_box.bottom) - table_box.top; bar = document.createElement("div"); bar.className = "line-hl"; bar.style.top = top + "px"; bar.style.height = (bottom - top) + "px"; table.appendChild(bar); - /* The browser only scrolls to fragments that name a real id, so - * bring ranges into view ourselves. */ - if (m[2]) - a.scrollIntoView({ block: "center" }); + + // A browser only scrolls to a fragment that names a real id, which a + // range never does, so bring the start of one into view here. + if (match[2]) + first.scrollIntoView({ block: "center" }); } -document.addEventListener("DOMContentLoaded", place, false); -window.addEventListener("hashchange", place, false); +document.addEventListener("DOMContentLoaded", place_bar, false); +window.addEventListener("hashchange", place_bar, false); })(); -/* Colour theme toggle: cycles auto -> light -> dark and remembers the - * choice. "auto" leaves the page following the system preference via CSS. */ +/* + * The colour theme toggle, which cycles from auto through light to dark and + * remembers the choice. Auto sets no override at all, leaving the page to + * follow the system preference the way the stylesheet does on its own. + */ (function () { -var KEY = "cgit-theme"; +var STORAGE_KEY = "cgit-theme"; var ORDER = [ "auto", "light", "dark" ]; var ICONS = { auto: '', @@ -152,25 +184,42 @@ var ICONS = { dark: '' }; +/* + * Local storage throws instead of answering when the browser has storage + * switched off for the site, and a colour preference is not worth breaking + * the page over, so both of these swallow the failure and the reader is left + * on the automatic theme. + */ function saved() { - try { return localStorage.getItem(KEY); } catch (e) { return null; } + try { + return localStorage.getItem(STORAGE_KEY); + } catch (e) { + return null; + } } -function persist(value) { - try { localStorage.setItem(KEY, value); } catch (e) { } +function persist(theme) { + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch (e) { } } -function apply(theme, btn) { +function apply(theme, button) { var html = document.documentElement; + if (theme === "auto") html.removeAttribute("data-theme"); else html.setAttribute("data-theme", theme); - /* Keep the standalone page background in sync with the choice. */ + + // The browser paints the page canvas and the scrollbars from + // color-scheme rather than from the stylesheet, so it has to be told + // the choice as well. html.style.colorScheme = (theme === "auto") ? "" : theme; - if (btn) { - btn.innerHTML = ICONS[theme]; - btn.title = "Colour theme: " + theme; + + if (button) { + button.innerHTML = ICONS[theme]; + button.title = "Colour theme: " + theme; } } @@ -178,12 +227,13 @@ var theme = saved(); if (ORDER.indexOf(theme) < 0) theme = "auto"; -/* Settled here rather than on DOMContentLoaded. This file is fetched from - * and runs before the body is parsed, so the choice lands on - * ahead of the first paint and the page never shows the other theme first. - * The marker sits on for the same reason, since div#cgit does not - * exist yet. cgit-js reserves the toggle's column so revealing the button - * below does not shift the header. */ +// This runs while the file is being parsed rather than on DOMContentLoaded. +// The script is fetched from the head and so runs before the body exists, +// which lands the choice ahead of the first paint and stops the page showing +// the other theme for a moment. The marker goes on the same element for the +// same reason, since div#cgit has not been parsed yet, and cgit-js reserves +// the toggle's column so that revealing the button below does not shift the +// header sideways. apply(theme, null); document.documentElement.classList.add("cgit-js"); @@ -191,16 +241,18 @@ document.addEventListener("DOMContentLoaded", function () { var root = document.getElementById("cgit"); if (!root) return; - var btn = root.querySelector(".theme-toggle"); - if (!btn) + var button = root.querySelector(".theme-toggle"); + if (!button) return; - apply(theme, btn); - btn.hidden = false; - btn.addEventListener("click", function () { + apply(theme, button); + // The button is served hidden so that a reader without scripting is + // never shown a control that cannot do anything. + button.hidden = false; + button.addEventListener("click", function () { theme = ORDER[(ORDER.indexOf(theme) + 1) % ORDER.length]; persist(theme); - apply(theme, btn); + apply(theme, button); }); }, false); -- cgit v2.8.0