@vernikr/size-report 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/page/app.js CHANGED
@@ -2,15 +2,20 @@ import { appData, appUi, appView, appWrite, appNotice, appLinkUse, appRead, appA
2
2
  import { appBody, appHead, appState } from './table.js';
3
3
  import { appPanel } from './panel.js';
4
4
 
5
- /* Сборка таблицы: что показывать (метрики и файлы, оставленные читателем) и куда
6
- * это положить. Разметку шапки и строк строит глава таблицы, числаобщий расчёт:
7
- * здесь остаётся только решение и вставка, своих чисел у сборки нет. */
5
+ /* Assembling the table: what to show (the metrics and files the reader left on) and where to put it. The table
6
+ * chapter builds the markup of the head and the rows, the shared calculation gives the numbers all that is
7
+ * left here is the decision and the insertion, with no numbers of its own. */
8
8
  function appTable() {
9
9
  const shown = appData.metrics.filter((m) => appView.metrics[m.key]);
10
10
  const metrics = shown.map((m) => m.key);
11
11
  const on = appView.files;
12
12
  const files = [];
13
13
  appData.files.forEach((f, i) => { if (on[i]) files.push(i); });
14
+ /* The columns the last commit touched come first: the report is rebuilt after every commit, and a reader's first
15
+ * question is what that edit brought. Inside each part the order stays as it comes from the settings — `sort` is
16
+ * stable, and the order of the columns is what the reader is used to. The mark comes from the history (which the
17
+ * engine knows) rather than from the numbers: an edit that changed no size is an edit too. */
18
+ files.sort((a, b) => (appData.last[a] === true ? 0 : 1) - (appData.last[b] === true ? 0 : 1));
14
19
 
15
20
  const table = document.getElementById('grid');
16
21
  table.textContent = '';
@@ -25,12 +30,11 @@ function appTable() {
25
30
  appWrite();
26
31
  }
27
32
 
28
- /* Прокрутка панели свойство панели, а не разметки, поэтому она переживает
29
- * пересборку: иначе каждый клик по галочке возвращал бы список к началу, и до
30
- * нижних файлов дерева было бы не добраться. Запоминается прокрутка панели и
31
- * списка файлов у каждого она своя, а в узком окне прокручивается список.
32
- * Элементы берутся те, что есть в разметке страницы (`src/page/build.js`):
33
- * второго перечисления мест прокрутки в пакете нет. */
33
+ /* The panel's scroll is a property of the panel rather than of the markup, which is why it survives a rebuild:
34
+ * otherwise every click on a checkbox would send the list back to the top and the files at its end would be
35
+ * unreachable. Both the panel's scroll and the file list's are remembered — each has one of its own, and in a
36
+ * narrow window it is the list that scrolls. The elements are the ones the page really has (`#panel` from the
37
+ * markup, `.files` inside it from the panel): there is no second list of scroll places in the package. */
34
38
  const appScrolled = ['#panel', '#panel .files'];
35
39
  function appScrollTop() {
36
40
  return appScrolled.map((sel) => {
@@ -46,12 +50,11 @@ function appScrollBack(saved) {
46
50
  });
47
51
  }
48
52
 
49
- /* Панель перерисовывается целиком, поэтому поле, стоящее под клавиатурой, и
50
- * прокрутка после каждой пересборки возвращаются на своё место: иначе
51
- * переключение с Tab и Space требовало бы начинать обход панели заново, а
52
- * прокрутка искать своё место заново. Место поля опознаётся порядковым номером
53
- * порядок полей панели от данных не зависит. Фокус ставится без прокрутки (`preventScroll`):
54
- * он возвращает клавиатуру, а не двигает список. */
53
+ /* The panel is redrawn whole, so the field under the keyboard and the scroll come back to their places after every
54
+ * rebuild: otherwise switching with Tab and Space would mean walking the panel from the start again, and the scroll
55
+ * would have to find its place anew. A field is identified by its ordinal number — the order of the panel's fields
56
+ * does not change between rebuilds. The focus is set without scrolling (`preventScroll`): it returns the keyboard
57
+ * rather than moving the list. */
55
58
  function appRender(keepNotice) {
56
59
  const at = Array.from(document.querySelectorAll('#panel input')).indexOf(document.activeElement);
57
60
  const saved = appScrollTop();
@@ -59,16 +62,16 @@ function appRender(keepNotice) {
59
62
  appScrollBack(saved);
60
63
  if (at >= 0) document.querySelectorAll('#panel input')[at].focus({ preventScroll: true });
61
64
  appTable();
62
- /* Сообщение о ссылке переживает отрисовку, которая сама же им и вызвана, и
63
- * гаснет от действия читателя: он его уже прочитал. */
65
+ /* The message about the link survives the very drawing it caused, and fades on the reader's next action: he has read
66
+ * it by then. */
64
67
  if (keepNotice !== true) appNotice('');
65
68
  }
66
69
 
67
- /* Восстановление до первой отрисовки: у того, кто открыл страницу впервые,
68
- * разметка обязана быть умолчанием, а не чужим выбором. Ссылка старше памяти: это
69
- * явный выбор отправителя, и пока читатель ничего не менял, она его собственный
70
- * выбор не подменяет в память её запись не идёт. Отказ ссылки не пустая
71
- * таблица, а сообщение: читателю видно и что произошло, и что показано вместо. */
70
+ /* Restoring happens before the first drawing: for someone opening the page for the first time the view has to be the
71
+ * default rather than someone else's choice. A link outranks the memory: it is the sender's explicit choice, and
72
+ * while the reader has changed nothing it does not replace his own — writing it to the memory is what does not
73
+ * happen. A refused link is not an empty table but a message: the reader sees both what happened and what is shown
74
+ * instead. */
72
75
  const appStart = appLinkUse();
73
76
  if (appStart === 'ours') appTransient = true;
74
77
  else if (appStart === 'refused') appForeign = true;
@@ -76,20 +79,18 @@ if (appStart !== 'ours') {
76
79
  const appSaved = appRead();
77
80
  if (appSaved !== null) appApply(appSaved);
78
81
  }
79
- /* Сложенное дерево память смотрящего, а не выбор читателя: она возвращается и
80
- * тогда, когда открыта чужая ссылка (иначе присланная ссылка разложила бы дерево
81
- * заново на каждом заходе). */
82
+ /* The folded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone else's
83
+ * link is open (otherwise a link sent over would unfold the tree again on every visit). */
82
84
  appFoldRead();
83
85
  appRender(true);
84
86
  appStartup = false;
85
87
  appForeign = false;
86
88
  appTransient = false;
87
89
 
88
- /* Якорь сменился на открытой странице: выбор из нового адреса применяется тем же
89
- * кодом, что и при открытии. Свой собственный адрес такого события не поднимает
90
- * (`replaceState` его не вызывает), поэтому петли здесь нет. Отказ не трогает ни
91
- * вид читатель продолжает смотреть то, что смотрел, — ни адрес: его прислали
92
- * читателю, и до первого его действия это не наше. */
90
+ /* The anchor changed on an open page: the choice in the new address is applied by the same code as at opening. The
91
+ * page's own address raises no such event (`replaceState` does not), so there is no loop here. A refusal touches
92
+ * neither the view the reader keeps looking at what he looked at — nor the address: it was sent to the reader,
93
+ * and until he acts it is not ours. */
93
94
  window.addEventListener('hashchange', () => {
94
95
  const state = appLinkUse();
95
96
  if (state === 'refused') appForeign = true;
package/src/page/build.js CHANGED
@@ -2,27 +2,25 @@ import fs from 'fs';
2
2
  import { fill, LOCALES } from '../locales.js';
3
3
  import { PAGE_CSS, TABLE_CSS } from '../css.js';
4
4
 
5
- /* Экранирование текста в разметке здесь, потому что единственный, кто собирает
6
- * разметку из данных, эта сборка: остальное рисует страница узлами. */
5
+ /* Escaping text for markup lives here, because this builder is the only place that turns data into markup: the rest
6
+ * is drawn as nodes by the page. */
7
7
  export function esc(s) {
8
8
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9
9
  }
10
10
 
11
- /* Сборка страницы отчёта: данные и программа в одном файле, внешних ссылок нет.
12
- * Оформление тоже обычные файлы: общая часть таблицы (`table.css`) и своё
13
- * оформление страницы (`app.css`).
11
+ /* Building the report's page: data and program in one file, with no external reference. The styling comes as ordinary
12
+ * files too: the table's shared part (`table.css`) and the page's own (`app.css`).
14
13
  *
15
- * Программа страницы обычные исходники (главы `src/page/*.js` и общий расчёт
16
- * `src/derived.js`), а не строки в движке: их видит линтер, их же движок
17
- * вклеивает в страницу. Модульный синтаксис снимается при вклейке: в браузере,
18
- * открывшем файл с диска, разрешать `import` нечем, а объявления обязаны попасть
19
- * в общую область видимости в порядке вклейки — сперва расчёт, затем главы.
14
+ * The page's program is ordinary sources (the chapters in `src/page/*.js` and the shared calculation in
15
+ * `src/derived.js`) rather than strings inside the engine: a linter sees them, and the engine pastes them into the
16
+ * page. Module syntax is removed while pasting: in a browser that opened a file from disk there is nothing to resolve
17
+ * `import` with, and the declarations have to reach the shared scope in the order of pasting — the calculation first,
18
+ * then the chapters.
20
19
  *
21
- * Главы по предметам страницы, и порядок вклейки (список ниже) это порядок
22
- * объявлений в собранной программе: первым идёт расчёт, за ним состояние
23
- * выбора, узлы, панель, таблица и сборка. Главы **срезы одного текста**: вклейка
24
- * склеивает их подряд, поэтому собранная страница осталась бы той же, если бы
25
- * главы снова стали одним файлом. */
20
+ * The chapters follow the page's subjects, and the order of pasting (the list below) is the order of declarations in
21
+ * the assembled program: the calculation first, then the choice's state, the nodes, the panel, the table and the
22
+ * assembling. The chapters are **slices of one text**: pasting glues them in a row, so the assembled page would stay
23
+ * the same if the chapters became one file again. */
26
24
  export function stripModules(src) {
27
25
  return src.split('\n')
28
26
  .filter((line) => !/^import\s.*;\s*$/.test(line))
@@ -34,16 +32,16 @@ export function pageSource(file) {
34
32
  return stripModules(fs.readFileSync(new URL(file, import.meta.url), 'utf8'));
35
33
  }
36
34
 
37
- /* Список глав здесь же, а не в проверках: он один на сборку и на сторожа
38
- * (`test/page-view.test.js` читает ту же программу и сверяет её с исходниками). */
35
+ /* The list of chapters lives here rather than in the tests: one copy for the builder and for the guard
36
+ * (`test/page-view.test.js` reads the same program and compares it with the sources). */
39
37
  export const PAGE_PARTS = ['./state.js', './dom.js', './panel.js', './table.js', './app.js'];
40
38
 
41
39
  export function pageScript() {
42
40
  return pageSource('../derived.js') + '\n' + PAGE_PARTS.map((part) => pageSource(part)).join('');
43
41
  }
44
42
 
45
- /* Подпись под заголовком: чем собран отчёт и где он лежит. Путь текстом, а не
46
- * ссылкой: страница открывается с диска и ни от чего не зависит. */
43
+ /* The note under the heading: what built the report and where it lies. The path is plain text rather than a link: the
44
+ * page opens from disk and depends on nothing. */
47
45
  function subText(data, page) {
48
46
  return fill(page.sub, {
49
47
  tool: data.tool.name,
@@ -52,8 +50,8 @@ function subText(data, page) {
52
50
  });
53
51
  }
54
52
 
55
- /* Тексты страницы: заголовки колонок, подписи панели, легенда и состояния. В
56
- * артефакт они не идут это словарь страницы, а не отчёта. */
53
+ /* The page's texts: column captions, panel labels, the legend and the empty states. They are the page's dictionary
54
+ * rather than the report's: the data block carries none of them, and the report's own words live in the locale. */
57
55
  function uiText(page, loc) {
58
56
  return {
59
57
  commit: loc.commit,
@@ -69,29 +67,35 @@ function uiText(page, loc) {
69
67
  linkForeign: page.linkForeign,
70
68
  linkBroken: page.linkBroken,
71
69
  linkExtra: page.linkExtra,
72
- /* Точность двумя словами: подпись метрики говорит про худшее в колонке,
73
- * подсказка клетки про её собственное число. */
70
+ /* Accuracy in two words: a metric's caption speaks about the worst in its column, while a cell's tooltip speaks
71
+ * about its own number. */
74
72
  exact: page.exact,
75
73
  approximate: page.approximate,
76
74
  approxCell: page.approximateCell,
77
- /* Почему файла нет в отчёте словами: причину называет движок знаком (`why`),
78
- * а страница одевает знак в текст, как и всё остальное в панели. */
75
+ /* Why a file is not in the report, in words: the engine names the reason with a mark (`why`), and the page dresses
76
+ * the mark in text, as it does with everything else in the panel. */
79
77
  notMeasuredRule: page.notMeasuredRule,
80
78
  notMeasuredChoice: page.notMeasuredChoice,
79
+ /* The tooltip of a file's checkbox: where the file stands and how its category was decided. The
80
+ * panel keeps no words of its own — a Russian report stays Russian in its chrome too, and an
81
+ * English one gets English there. */
82
+ notOnHead: page.notOnHead,
83
+ category: page.category,
84
+ categoryFromConfig: page.categoryFromConfig,
85
+ categoryByExtension: page.categoryByExtension,
81
86
  methodLabel: page.panelMethod,
82
87
  empty: page.emptyMetrics,
83
88
  noFiles: page.noFiles,
84
- /* {command} подставляет страница: у неё есть данные, а {now} уже здесь. */
89
+ /* {command} is substituted by the page, which holds the data, while {now} is filled in here. */
85
90
  note: page.note.replace(/\{now\}/g, loc.now)
86
91
  };
87
92
  }
88
93
 
89
- /* Что в файл не идёт. Первое список пропущенных коммитов: он меняется от
90
- * коммита самого отчёта (тот, кому нечего сказать, попадает в список), и файл
91
- * перестал бы быть **неподвижной точкой** пересборка после его же коммита давала
92
- * бы другие байты, а хук коммитил бы отчёт бесконечно. Странице этот список не
93
- * нужен вовсе: она его не показывает. Читателю он по-прежнему доступен — `--data`,
94
- * `--json` и `explain` отвечают этим же проходом. */
94
+ /* What does not go into the file. First, the list of skipped commits: it changes with the report's own commit (one
95
+ * with nothing to say lands in the list), and the file would stop being a **fixed point** — a rebuild after its own
96
+ * commit would yield different bytes and the hook would commit the report forever. The page has no use for the list
97
+ * at all: it does not show it. It stays available to the reader — `--data`, `--json` and `explain` answer from the
98
+ * same run. */
95
99
  const NOT_IN_FILE = ['skipped'];
96
100
 
97
101
  export function pagePayload(data) {
@@ -100,10 +104,8 @@ export function pagePayload(data) {
100
104
  return out;
101
105
  }
102
106
 
103
- /* Страница отчёта один файл: данные лежат в нём же, скрипт вклеен, внешних
104
- * ссылок нет. Поэтому она открывается двойным щелчком и работает без сети.
105
- * `<` в данных экранируется: иначе подпись коммита или путь закрыли бы тег
106
- * раньше времени (в JSON такой экранированный символ читается как обычный). */
107
+ /* The report's page is one file: the data lies in it, the script is pasted in, there are no external references. Hence
108
+ * it opens with a double click and works without a network. */
107
109
  export function pageHtml(data, cfg) {
108
110
  const loc = LOCALES[cfg.locale];
109
111
  return '<!doctype html>\n<html lang="' + esc(loc.html) + '">\n<head>\n<meta charset="utf-8">\n'
@@ -122,9 +124,8 @@ export function pageHtml(data, cfg) {
122
124
  + '<script>\n' + pageScript() + '</script>\n</body>\n</html>\n';
123
125
  }
124
126
 
125
- /* JSON внутри страницы: `<` экранируется, иначе подпись коммита или путь закрыли
126
- * бы тег раньше времени JSON такой экранированный символ читается как самый
127
- * обычный). */
127
+ /* JSON inside the page: `<` is escaped, or a commit's subject or a path would close the tag early (inside a JSON
128
+ * string such an escaped character reads as a most ordinary one). */
128
129
  function jsonInHtml(value) {
129
130
  return JSON.stringify(value).replace(/</g, '\\u003c');
130
131
  }
package/src/page/dom.js CHANGED
@@ -6,9 +6,8 @@ export function appEl(tag, cls, text) {
6
6
  return el;
7
7
  }
8
8
 
9
- /* Переключатель метка вокруг поля ввода: цель нажатия одна, поэтому по нему
10
- * попадают и мышь, и клавиатура (`Space` на поле ввода), и вспомогательные
11
- * технологии. Подпись видимая, подробности — во всплывающей строке. */
9
+ /* A switch is a label around an input: one click target, which is why a mouse, the keyboard (`Space` on the input)
10
+ * and assistive technology all reach it. The label is visible, the details live in the tooltip. */
12
11
  export function appBox(label, title, checked, onChange, cls) {
13
12
  const box = appEl('label', 'box' + (cls ? ' ' + cls : ''));
14
13
  const input = document.createElement('input');
@@ -20,3 +19,17 @@ export function appBox(label, title, checked, onChange, cls) {
20
19
  box.appendChild(appEl('span', null, label));
21
20
  return box;
22
21
  }
22
+
23
+ /* A checkbox with nothing to switch: the place in the tree exists while there is nothing to switch on — the file is
24
+ * not among the columns and the report does not measure it. The checkbox is off and unavailable: that way the row
25
+ * looks like every other one (the eye compares like with like) while showing that this is not "switched off by the
26
+ * reader" but "not measured". The reason lives in the tooltip. */
27
+ export function appOffBox(label, title, cls) {
28
+ const box = appBox(label, title, false, null, cls);
29
+ box.classList.add('plain');
30
+ box.querySelector('input').disabled = true;
31
+ /* The tooltip goes on the whole row rather than the input alone: a browser shows none for a disabled input, while
32
+ * the reader needs the reason right here. */
33
+ box.title = title;
34
+ return box;
35
+ }
package/src/page/panel.js CHANGED
@@ -1,62 +1,60 @@
1
- import { appEl, appBox } from './dom.js';
1
+ import { appEl, appBox, appOffBox } from './dom.js';
2
2
  import { appData, appUi, appView, appFileAt, appFoldSet, appMeasured } from './state.js';
3
3
 
4
- /* Галочку файла ставит только файл: и категория, и папка в дереве способы
5
- * переставить те же галочки сразу группой, а своего состояния у них нет. Иначе
6
- * одно и то же решение жило бы в двух местах и расходилось. */
4
+ /* Only a file sets a file's checkbox: both a category and a folder in the tree are ways to set the same checkboxes as
5
+ * a group and keep no state of their own. Otherwise one and the same decision would live in two places and drift
6
+ * apart. */
7
7
  function appFileBox(i) {
8
8
  const f = appData.files[i];
9
- const where = appFileAt(i) + (f.path === null ? ' (нет на HEAD)' : '');
10
- return appBox(f.label, where + ' · категория: '
11
- + (f.categoryBy === 'config' ? 'из настроек' : 'по расширению'), appView.files[i], (e) => {
9
+ const where = appFileAt(i) + (f.path === null ? appUi.notOnHead : '');
10
+ return appBox(f.label, where + appUi.category
11
+ + (f.categoryBy === 'config' ? appUi.categoryFromConfig : appUi.categoryByExtension),
12
+ appView.files[i], (e) => {
12
13
  appView.files[i] = e.target.checked;
13
14
  appRender();
14
15
  });
15
16
  }
16
17
 
17
- /* Файл, которого в отчёте нет: он стоит в дереве на своём месте, но галочки у
18
- * него нет чисел для него не измеряли, и переключать нечего. Причину читатель
19
- * видит во всплывающей строке, а не догадывается по виду. */
18
+ /* A file that is not in the report: it stands in its place in the tree with its checkbox off and unavailable — no
19
+ * numbers were measured for it, so there is nothing to switch. The reader sees the reason in the tooltip rather than
20
+ * guessing it from the look. */
20
21
  function appUnmeasuredBox(entry) {
21
- const box = appEl('span', 'box plain', entry.path.split('/').pop());
22
- box.title = entry.path + ' · '
23
- + (entry.why === 'rule' ? appUi.notMeasuredRule : appUi.notMeasuredChoice);
24
- return box;
22
+ return appOffBox(entry.path.split('/').pop(), entry.path + ' · '
23
+ + (entry.why === 'rule' ? appUi.notMeasuredRule : appUi.notMeasuredChoice));
25
24
  }
26
25
 
27
- /* Все измеряемые файлы поддеревато, чем управляет переключатель папки: файл
28
- * вне отчёта переключать нечего. */
26
+ /* Every measured file of a subtree what a folder's switch controls: a file outside the report has nothing to
27
+ * switch. */
29
28
  function appIndexes(node) {
30
29
  const out = node.files.slice();
31
30
  node.dirs.forEach((sub) => { out.push(...appIndexes(sub)); });
32
31
  return out;
33
32
  }
34
33
 
35
- /* Сколько файлов в поддеревевместе с теми, что в отчёт не попали. */
34
+ /* How many files a subtree holds including the ones that made it into no report. */
36
35
  function appCount(node) {
37
36
  let n = node.files.length + node.others.length;
38
37
  node.dirs.forEach((sub) => { n += appCount(sub); });
39
38
  return n;
40
39
  }
41
40
 
42
- /* Узел дерева: измеряемые файлы (колонки), прочие файлы проекта и подпапки. */
41
+ /* A tree node: the measured files (columns), the project's other files and the subfolders. */
43
42
  function appNode() {
44
43
  return { files: [], others: [], dirs: new Map() };
45
44
  }
46
45
 
47
- /* Переключатель папки: его галочка ведёт за собой всё поддерево и показывает три
48
- * состояния все файлы включены, часть, ни одного. Рядом число файлов; если в
49
- * папке есть и те, что вне отчёта, оно написано долей («2/5»): читателю важно, что
50
- * в папке пять файлов, а измеряются два. Папка без единого измеряемого файла
51
- * галочки не получает — включать в ней нечего, — но на месте остаётся. */
46
+ /* A folder's switch: its checkbox carries the whole subtree with it and shows three states — every file on, some,
47
+ * none. The number of files stands next to it; when the folder also holds files outside the report, it is written as
48
+ * a fraction ("2/5"): what matters to the reader is that the folder holds five files while two are measured. A folder
49
+ * without a single measured file stays in place with its checkbox off and unavailable: there is nothing to switch on
50
+ * in it. */
52
51
  function appDirHead(name, sub) {
53
52
  const idx = appIndexes(sub);
54
53
  const total = appCount(sub);
55
54
  const label = name + '/';
56
55
  let head;
57
56
  if (idx.length === 0) {
58
- head = appEl('span', 'box dir plain', label);
59
- head.title = appUi.dirNone.replace('{name}', name).replace('{n}', total);
57
+ head = appOffBox(label, appUi.dirNone.replace('{name}', name).replace('{n}', total), 'dir');
60
58
  } else {
61
59
  const on = idx.map((i) => appView.files[i]);
62
60
  const every = on.every((v) => v);
@@ -71,22 +69,33 @@ function appDirHead(name, sub) {
71
69
  return head;
72
70
  }
73
71
 
74
- /* Знак папки своя цель нажатия, отдельная от галочки: галочка отвечает за числа
75
- * (включает файлы поддерева), а знак за то, сколько дерева видно. Одна цель на
76
- * два разных действия означала бы, что сложить папку можно только вместе с
77
- * включением её файлов. Знак нарисован спаном, а не кнопкой, потому что в строке
78
- * папки рядом уже стоит метка-галочка, а вложенная в метку кнопка поднимала бы её
79
- * же нажатие. */
72
+ /* The folder's sign is a click target of its own, separate from the checkbox: the checkbox answers for the numbers (it
73
+ * switches the subtree's files on), while the sign answers for how much of the tree is visible. One target for two
74
+ * different decisions would mean a folder can be folded only together with switching its files on. The sign is drawn
75
+ * as a span rather than a button and stands beside the label rather than inside it: a label is one click target, and a
76
+ * control nested in it would be reached as that same target.
77
+ *
78
+ * A click on the sign rebuilds nothing: the subtree lies in the markup and a class on the row hides it. A rebuild here
79
+ * would be honest work for nothing — it counts the whole table (every row by every column) and so pays for numbers
80
+ * folding does not change. That is why only the three things the reader sees change: the class, the sign and the note
81
+ * in the memory. */
80
82
  function appFoldBox(name, path) {
81
83
  const folded = appView.folded[path] === true;
82
84
  const box = appEl('span', 'fold', folded ? '▸' : '▾');
83
85
  box.title = (folded ? appUi.foldOpen : appUi.foldClose).replace('{name}', name);
84
- box.addEventListener('click', () => { appFoldSet(path, !folded); appRender(); });
86
+ box.addEventListener('click', () => {
87
+ const now = !(appView.folded[path] === true);
88
+ appFoldSet(path, now);
89
+ const li = box.closest('li');
90
+ if (li !== null) li.classList.toggle('folded', now);
91
+ box.textContent = now ? '▸' : '▾';
92
+ box.title = (now ? appUi.foldOpen : appUi.foldClose).replace('{name}', name);
93
+ });
85
94
  return box;
86
95
  }
87
96
 
88
- /* Листья уровня: измеряемые файлы и файлы вне отчёта вперемешку и по алфавиту
89
- * имени, как в дереве файлов, а не отдельными списками. */
97
+ /* The leaves of one level: measured files and files outside the report mixed together and sorted by name, the way a
98
+ * file tree reads, rather than as separate lists. */
90
99
  function appLeaves(node) {
91
100
  const items = node.files.map((i) => ({ name: appData.files[i].label, i: i, entry: null }));
92
101
  node.others.forEach((entry) => {
@@ -95,31 +104,44 @@ function appLeaves(node) {
95
104
  return items.sort((a, b) => (a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)));
96
105
  }
97
106
 
98
- /* Узлы одного уровня: сперва папки по алфавиту, затем листья (их порядок из
99
- * `appLeaves`). Сложенная папкаэто та, у которой нет самого списка: прятать
100
- * поддерево оформлением значило бы держать в разметке то, чего не видно, и
101
- * пересобирать её на каждый клик по знаку. */
107
+ /* A folder row: the folding sign, the checkbox with the number of files and the subtree. A folded folder differs by
108
+ * its class alonethe markup stays the same. */
109
+ function appDir(name, sub, prefix) {
110
+ const here = prefix === '' ? name : prefix + '/' + name;
111
+ const folded = appView.folded[here] === true;
112
+ const li = appEl('li', folded ? 'folded' : null);
113
+ li.appendChild(appFoldBox(name, here));
114
+ li.appendChild(appDirHead(name, sub));
115
+ li.appendChild(appTreeList(sub, here));
116
+ return li;
117
+ }
118
+
119
+ // A leaf row: a measured file comes with a checkbox, a file outside the report with one switched off.
120
+ function appLeaf(leaf) {
121
+ const li = appEl('li');
122
+ li.appendChild(leaf.entry === null ? appFileBox(leaf.i) : appUnmeasuredBox(leaf.entry));
123
+ return li;
124
+ }
125
+
126
+ /* The nodes of one level: first everything in the report (folders alphabetically, then the leaves in the order
127
+ * `appLeaves` gives), then what is not in it — folders without a measured file and files outside the columns. That is
128
+ * not a matter of taste: everything outside the report has its checkbox off and unavailable, so at the end of the list
129
+ * it does not distract from what is in the table, while it can still be found — in the same place where it was. */
102
130
  function appTreeList(node, prefix) {
103
131
  const list = appEl('ul', 'tree');
104
- [...node.dirs.keys()].sort().forEach((name) => {
105
- const sub = node.dirs.get(name);
106
- const here = prefix === '' ? name : prefix + '/' + name;
107
- const li = appEl('li');
108
- li.appendChild(appFoldBox(name, here));
109
- li.appendChild(appDirHead(name, sub));
110
- if (appView.folded[here] !== true) li.appendChild(appTreeList(sub, here));
111
- list.appendChild(li);
112
- });
113
- appLeaves(node).forEach((leaf) => {
114
- const li = appEl('li');
115
- li.appendChild(leaf.entry === null ? appFileBox(leaf.i) : appUnmeasuredBox(leaf.entry));
116
- list.appendChild(li);
117
- });
132
+ const dirs = [...node.dirs.keys()].sort()
133
+ .map((name) => ({ name: name, sub: node.dirs.get(name), inReport: appIndexes(node.dirs.get(name)).length > 0 }));
134
+ const leaves = appLeaves(node);
135
+ const inside = leaves.filter((leaf) => leaf.entry === null);
136
+ dirs.filter((d) => d.inReport).forEach((d) => list.appendChild(appDir(d.name, d.sub, prefix)));
137
+ inside.forEach((leaf) => list.appendChild(appLeaf(leaf)));
138
+ dirs.filter((d) => !d.inReport).forEach((d) => list.appendChild(appDir(d.name, d.sub, prefix)));
139
+ leaves.filter((leaf) => leaf.entry !== null).forEach((leaf) => list.appendChild(appLeaf(leaf)));
118
140
  return list;
119
141
  }
120
142
 
121
- /* Место листа в дереве: путь делится по «/», промежуточные папки заводятся по
122
- * дороге. Одно место на измеряемые и прочие иначе они разошлись бы папками. */
143
+ /* A leaf's place in the tree: the path is split on "/" and the intermediate folders are created on the way. One place
144
+ * for measured and other files alikeotherwise they would drift apart in folders. */
123
145
  function appLeafAt(node, p, i, entry) {
124
146
  const parts = p.split('/');
125
147
  for (let d = 0; d < parts.length - 1; d++) {
@@ -130,11 +152,10 @@ function appLeafAt(node, p, i, entry) {
130
152
  else node.others.push(entry);
131
153
  }
132
154
 
133
- /* Дерево страницы дерево проекта: узлы берутся из каталога (все пути, которые
134
- * видит git), поэтому в нём есть и файлы вне отчёта. Измеряемый лист колонка, и
135
- * путь у него тот же, что в подписи файла; колонка, чьего файла на HEAD уже нет, в
136
- * каталог не попала (в индексе её нет) и стоит на месте по последнему известному
137
- * пути. */
155
+ /* The page's tree is the project's tree: the nodes come from the catalogue (every path git sees), which is why it also
156
+ * holds files outside the report. A measured leaf is a column, and its path is the one in the file's caption; a column
157
+ * whose file is gone from HEAD never entered the catalogue (the index does not hold it) and stands in place by the last
158
+ * path known. */
138
159
  function appTree() {
139
160
  const root = appNode();
140
161
  appData.files.forEach((_f, i) => appLeafAt(root, appFileAt(i), i, null));
@@ -159,9 +180,9 @@ export function appPanel() {
159
180
  }, 'metric'));
160
181
  });
161
182
  metrics.appendChild(mrow);
162
- /* Чем получено каждое число видно, а не только во всплывающей строке: словарь
163
- * токенов и способ сжатия выбираются настройками запуска, переключить их
164
- * странице нечем, и читателю важно знать это, не наводя мышь. */
183
+ /* What produced each number is visible rather than hidden in a tooltip: the token dictionary and the way of
184
+ * compression are chosen by the settings of the run, the page has nothing to switch them with, and the reader needs
185
+ * to know this without pointing a mouse. */
165
186
  appData.metrics.forEach((m) => {
166
187
  metrics.appendChild(appEl('p', 'about', m.label + ' — ' + appUi.methodLabel + ' ' + m.method));
167
188
  });
@@ -169,8 +190,8 @@ export function appPanel() {
169
190
 
170
191
  const files = appEl('fieldset', 'files');
171
192
  files.appendChild(appEl('legend', null, appUi.files));
172
- /* Строка категорий помечена классом: список файлов листается, и она остаётся на
173
- * виду (липкость в широкой раскладке, там панель и прокручивается). */
193
+ /* The row of categories is marked by a class: the file list scrolls, and the row stays in sight (its stickiness lives
194
+ * in the wide layout, which is where the panel scrolls). */
174
195
  const cats = appEl('div', 'row cats');
175
196
  appData.categories.forEach((cat) => {
176
197
  const idx = [];