@ok-ggwp/git-gloss 0.3.0 → 0.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.
@@ -1,15 +1,24 @@
1
- // Renders the gloss page from the embedded JSON. All text goes through
2
- // textContent; nothing from the data is ever parsed as HTML.
1
+ // Renders the gloss page (the "review desk") from the embedded JSON: a rail of
2
+ // groups, or of commits for a range, and a detail pane for the selection.
3
+ // All text goes through textContent; nothing from the data is ever parsed as
4
+ // HTML. Colours reach elements as custom properties set through the CSSOM,
5
+ // which the page's CSP allows (it blocks style attributes).
3
6
  (function () {
4
7
  'use strict'
5
8
  var data = JSON.parse(document.getElementById('gloss-data').textContent)
6
9
  var app = document.getElementById('app')
7
- var hidden = new Set() // necessity values filtered out
8
- var multi = data.commits.length > 1 || data.range !== null
9
- // Group ids are unique within a commit, not across a range, so a range page
10
- // keys cards by commit too: #g-<sha7>-<group>.
11
- function key(c, id) { return multi ? c.sha.slice(0, 7) + '-' + id : id }
10
+ var commits = data.commits
11
+ var multi = commits.length > 1 || data.range !== null
12
+ var IMPACTS = data.impact.map(function (i) { return i.name })
13
+ var LEVELS = ['skim', 'look', 'careful', 'must-read']
14
+ var FILTERS = [['all', 'All'], ['focus', 'Look first'], ['contract', 'Contract and above'], ['low', 'Local only']]
12
15
 
16
+ // What is shown: a commit (index) and in it a group, the overview or the
17
+ // unannotated changes. Plus the filters and whether diffs are open.
18
+ var booting = true // no scrolling while the first selection is made
19
+ var state = { ci: 0, kind: 'overview', gid: null, filter: 'all', hidden: new Set(), hot: true, stage: null, back: null }
20
+
21
+ // ---- helpers ----
13
22
  function el(tag, cls, text) {
14
23
  var e = document.createElement(tag)
15
24
  if (cls) e.className = cls
@@ -20,38 +29,150 @@
20
29
  for (var i = 1; i < arguments.length; i++) if (arguments[i]) parent.appendChild(arguments[i])
21
30
  return parent
22
31
  }
23
- function color(e, necessity) {
24
- e.style.setProperty('--c', 'var(--n-' + necessity + ')')
25
- return e
32
+ function text(s) { return document.createTextNode(s) }
33
+ function button(cls, label, onclick) {
34
+ var b = el('button', cls, label)
35
+ b.type = 'button'
36
+ if (onclick) b.addEventListener('click', onclick)
37
+ return b
26
38
  }
27
39
  function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
40
+ function sha7(c) { return c.sha.slice(0, 7) }
41
+ // Group ids are unique within a commit, not across a range, so a range keys
42
+ // groups by commit too: #g-<sha7>-<group>.
43
+ function key(c, id) { return multi ? sha7(c) + '-' + id : id }
28
44
  function stats(added, removed) {
29
- return add(el('span', 'stats'), el('span', 'plus', '+' + added), document.createTextNode(' '), el('span', 'minus', '−' + removed))
45
+ return add(el('span', 'stats'), el('span', 'plus', '+' + added), text(' '), el('span', 'minus', '−' + removed))
46
+ }
47
+ function impactRank(level) { return IMPACTS.indexOf(level) + 1 }
48
+ function levelRank(level) { return LEVELS.indexOf(level) + 1 }
49
+ // Declared impact, else the one derived from the diff (only present where the
50
+ // diff puts a group above the baseline), else none.
51
+ function effImpact(g) { return g.impact || g.impact_derived || null }
52
+ function find(list, fn) { for (var i = 0; i < list.length; i++) if (fn(list[i])) return list[i]; return null }
53
+ function groupOf(c, id) { return find(c.groups, function (g) { return g.id === id }) }
54
+ function orderedGroups(c) { return c.order.map(function (id) { return groupOf(c, id) }).filter(Boolean) }
55
+ function isFocus(c, g) { return !!(c.attention && c.attention.focus && c.attention.focus.indexOf(g.id) >= 0) }
56
+
57
+ // The level of a commit as the reader should treat it: declared, else derived.
58
+ function levelOf(c) {
59
+ if (!c.glossed) return { level: null, source: 'none' }
60
+ if (c.attention) return { level: c.attention.level, source: 'declared' }
61
+ if (c.computed) return { level: c.computed.derived.level, source: 'derived' }
62
+ return { level: null, source: 'unrated' }
63
+ }
64
+ function fileCount(c) {
65
+ var seen = Object.create(null)
66
+ c.groups.forEach(function (g) { g.hunks.forEach(function (h) { seen[h.file] = true }) })
67
+ c.unannotated.forEach(function (h) { seen[h.file] = true })
68
+ return Object.keys(seen).length
69
+ }
70
+
71
+ // ---- read state, kept per commit in sessionStorage (this tab only) when it is available ----
72
+ var readCache = {}
73
+ function readSet(c) {
74
+ if (!readCache[c.sha]) {
75
+ var s = Object.create(null) // no prototype: a group id like "constructor" must not read as already read
76
+ try {
77
+ var raw = window.sessionStorage.getItem('git-gloss:read:' + c.sha)
78
+ if (raw) JSON.parse(raw).forEach(function (id) { s[id] = true })
79
+ } catch (e) { /* storage blocked or unreadable: marks are kept in memory for this visit only */ }
80
+ readCache[c.sha] = s
81
+ }
82
+ return readCache[c.sha]
83
+ }
84
+ function setRead(c, id, on) {
85
+ var s = readSet(c)
86
+ if (on) s[id] = true; else delete s[id]
87
+ try { window.sessionStorage.setItem('git-gloss:read:' + c.sha, JSON.stringify(Object.keys(s))) } catch (e) { /* not persisted */ }
88
+ }
89
+ function readCounts() {
90
+ var read = 0, total = 0
91
+ commits.forEach(function (c) {
92
+ var s = readSet(c)
93
+ c.groups.forEach(function (g) { total++; if (s[g.id]) read++ })
94
+ })
95
+ return { read: read, total: total }
96
+ }
97
+
98
+ // ---- small widgets ----
99
+ function meter(level) {
100
+ var m = el('span', level ? 'meter' : 'meter unrated')
101
+ m.setAttribute('aria-hidden', 'true')
102
+ var n = impactRank(level)
103
+ for (var i = 1; i <= 5; i++) m.appendChild(el('i', i <= n ? 'on' : null))
104
+ if (level) m.style.setProperty('--m', 'var(--impact-' + level + ')')
105
+ return m
106
+ }
107
+ function gauge(level, small) {
108
+ var g = el('span', small ? 'gauge small' : 'gauge')
109
+ g.setAttribute('aria-hidden', 'true')
110
+ for (var i = 1; i <= 4; i++) g.appendChild(el('i', i <= levelRank(level) ? 'on' : null))
111
+ return g
112
+ }
113
+ function impactWord(g) {
114
+ var level = effImpact(g)
115
+ return level ? level + ' impact' + (g.impact ? '' : ' (derived)') : 'impact not rated'
30
116
  }
31
117
  function groupLink(c, id) {
32
118
  var a = el('a', null, id)
33
119
  a.href = '#g-' + key(c, id)
34
- a.addEventListener('click', function (ev) { ev.preventDefault(); focusGroup(key(c, id), true) })
120
+ a.addEventListener('click', function (ev) { ev.preventDefault(); selectGroup(commits.indexOf(c), id) })
35
121
  return a
36
122
  }
123
+ function linkList(c, ids) {
124
+ var span = el('span')
125
+ ids.forEach(function (id, i) { if (i) span.appendChild(text(', ')); span.appendChild(groupLink(c, id)) })
126
+ return span
127
+ }
128
+ function commitMeta(c) {
129
+ return add(el('div', 'commit'), el('span', 'sha', c.sha.slice(0, 10)),
130
+ el('span', null, c.author), el('span', null, new Date(c.date).toLocaleString()))
131
+ }
132
+ function statement(parent, label, body) {
133
+ if (body) add(parent, add(el('p', 'statement'), el('span', 'label', label), text(body)))
134
+ }
135
+ function warnings(c, parent) {
136
+ c.warnings.forEach(function (w) { add(parent, el('div', 'warning', '⚠ ' + w)) })
137
+ }
37
138
 
38
139
  // ---- diff rendering ----
39
- function hunkView(c, h) {
40
- var head = add(el('div', 'hunk-head'),
41
- el('span', 'range', h.ref),
42
- el('span', 'file', h.oldFile ? h.oldFile + ' → ' + h.file : h.file),
43
- el('span', 'range', h.range),
44
- stats(h.added, h.removed))
140
+ // Each hunk folds on its own; the header row stays. Open/closed lives in
141
+ // memory for the page's lifetime (not in storage), keyed by commit, scope
142
+ // (group id or 'un') and the hunk's reference. The DOM is the source of truth
143
+ // for what is open: syncFold() reads it to set the Expand/Collapse buttons.
144
+ var hunkClosed = {}
145
+ function setHunkOpen(wrap, open) {
146
+ var toggle = wrap.querySelector('.htoggle')
147
+ if (!toggle) return
148
+ if (open) delete hunkClosed[wrap.getAttribute('data-fold')]; else hunkClosed[wrap.getAttribute('data-fold')] = true
149
+ wrap.querySelector('.scroll').hidden = !open
150
+ toggle.setAttribute('aria-expanded', String(open))
151
+ toggle.firstChild.textContent = open ? '▾' : '▸'
152
+ wrap.classList.toggle('closed', !open)
153
+ }
154
+ function hunkView(c, h, scope) {
155
+ var name = h.oldFile ? h.oldFile + ' → ' + h.file : h.file
156
+ var head = el('div', 'hunk-head')
157
+ var wrap = el('div', 'hunk')
158
+ var foldKey = c.sha + '|' + scope + '|' + h.ref
159
+ wrap.setAttribute('data-fold', foldKey)
160
+ var toggle = null
161
+ if (h.status === 'missing') add(head, el('span', 'file', name))
162
+ else {
163
+ var chev = el('span', 'chev', '▾')
164
+ chev.setAttribute('aria-hidden', 'true')
165
+ toggle = add(button('htoggle', null), chev, el('span', 'file', name))
166
+ toggle.setAttribute('data-fid', 'hunk:' + foldKey)
167
+ toggle.addEventListener('click', function () { setHunkOpen(wrap, toggle.getAttribute('aria-expanded') !== 'true'); syncFold() })
168
+ }
169
+ add(head, toggle, el('span', 'range', h.ref), el('span', 'range', h.range), stats(h.added, h.removed))
45
170
  if (h.status === 'changed') add(head, el('span', 'stale', 'changed since glossed'))
46
171
  if (h.status === 'missing') {
47
172
  add(head, el('span', 'stale', 'no longer in this commit'))
48
- return add(el('div', 'hunk'), head)
49
- }
50
- if (h.alsoIn.length) {
51
- var also = add(el('span', 'also'), document.createTextNode('shares lines with '))
52
- h.alsoIn.forEach(function (id, i) { if (i) also.appendChild(document.createTextNode(', ')); also.appendChild(groupLink(c, id)) })
53
- add(head, also)
173
+ return add(wrap, head)
54
174
  }
175
+ if (h.alsoIn.length) add(head, add(el('span', 'also'), text('shares lines with '), linkList(c, h.alsoIn)))
55
176
  var table = el('table', 'diff')
56
177
  h.lines.forEach(function (l) {
57
178
  var tr = el('tr', l.kind)
@@ -68,316 +189,931 @@
68
189
  }
69
190
  table.appendChild(tr)
70
191
  })
71
- return add(el('div', 'hunk'), head, add(el('div', 'scroll'), table))
72
- }
73
-
74
- // ---- group cards ----
75
- var cards = []
76
- function groupCard(c, g) {
77
- var card = color(el('article', 'group'), g.necessity)
78
- card.id = 'g-' + key(c, g.id)
79
- card.tabIndex = -1
80
- card.dataset.necessity = g.necessity
81
-
82
- var title = add(el('div', 'group-title'),
83
- el('span', 'badge', g.necessity),
84
- el('span', 'intent', g.intent),
85
- el('span', 'gid', g.id))
86
- if (g.stale) add(title, el('span', 'stale', g.stale + ' of ' + plural(g.total, 'hunk') + ' changed or gone'))
87
- add(title, stats(g.added, g.removed))
88
-
89
- var facts = el('dl', 'facts')
90
- function fact(label, value) {
91
- if (!value) return
92
- add(facts, el('dt', null, label), typeof value === 'string' ? el('dd', null, value) : add(el('dd'), value))
93
- }
94
- if (g.serves[0] !== 'task') {
95
- var links = el('span')
96
- g.serves.forEach(function (id, i) { if (i) links.appendChild(document.createTextNode(', ')); links.appendChild(groupLink(c, id)) })
97
- fact('serves', links)
98
- }
99
- fact('if removed', g.removal_impact)
100
- fact('evidence', g.evidence)
101
- fact('notes', g.notes)
102
-
103
- add(card, add(el('div', 'group-head'), title), facts)
104
-
105
- if (g.hunks.length) {
106
- var code = el('div', 'code')
107
- code.hidden = true
108
- var toggle = el('button', 'code-toggle')
109
- toggle.type = 'button'
110
- var rendered = false
111
- function setOpen(open) {
112
- if (open && !rendered) { g.hunks.forEach(function (h) { code.appendChild(hunkView(c, h)) }); rendered = true }
113
- code.hidden = !open
114
- toggle.textContent = (open ? '▾ Hide code' : '▸ Show code') + ' · ' + plural(g.hunks.length, 'hunk')
115
- toggle.setAttribute('aria-expanded', String(open))
116
- }
117
- toggle.addEventListener('click', function () { setOpen(code.hidden) })
118
- setOpen(false)
119
- card.setOpen = setOpen
120
- card.isOpen = function () { return !code.hidden }
121
- add(card, toggle, code)
192
+ add(wrap, head, add(el('div', 'scroll'), table))
193
+ setHunkOpen(wrap, !hunkClosed[foldKey])
194
+ return wrap
195
+ }
196
+ // "Expand all hunks" / "Collapse all hunks" act on the hunks of the pane; the
197
+ // one that is already true is disabled (focus moves to the other one).
198
+ function syncFold() {
199
+ var expand = mainEl.querySelector('[data-fid="code:expand"]')
200
+ var collapse = mainEl.querySelector('[data-fid="code:collapse"]')
201
+ if (!expand) return
202
+ var toggles = mainEl.querySelectorAll('.htoggle')
203
+ var open = mainEl.querySelectorAll('.htoggle[aria-expanded="true"]').length
204
+ var a = document.activeElement
205
+ expand.disabled = open === toggles.length
206
+ collapse.disabled = open === 0
207
+ if (a && a.disabled && (a === expand || a === collapse)) {
208
+ var other = a === expand ? collapse : expand
209
+ if (!other.disabled) other.focus({ preventScroll: true })
210
+ }
211
+ }
212
+ function setCode(open) {
213
+ var wraps = mainEl.querySelectorAll('.hunk')
214
+ for (var i = 0; i < wraps.length; i++) setHunkOpen(wraps[i], open)
215
+ syncFold()
216
+ }
217
+
218
+ // ---- attention ----
219
+ // The declared attention (or the one derived from the diff, marked as such),
220
+ // the computed floor, the reasons and links to the groups to read first;
221
+ // "unrated" when nothing is declared and no signals exist.
222
+ function attentionCard(c) {
223
+ var a = c.attention, k = c.computed
224
+ var level = levelOf(c).level
225
+ var box = el('section', 'att')
226
+ box.setAttribute('aria-label', 'Attention')
227
+ if (level) box.style.setProperty('--lv', 'var(--attention-' + level + ')')
228
+ var lvl = add(el('div', 'lvl'), el('span', null, level ? level.toUpperCase() : 'UNRATED'), level ? gauge(level) : null)
229
+ if (a) add(lvl, el('span', 'src', 'declared'))
230
+ else if (k) {
231
+ var d = el('span', 'src', 'derived')
232
+ d.title = 'Derived from the diff, not declared in the gloss'
233
+ add(lvl, d)
234
+ }
235
+ if (k) add(lvl, el('span', 'src', 'floor ' + k.floor))
236
+ add(box, lvl)
237
+ var reasons = a ? a.reasons : k ? k.derived.reasons : []
238
+ if (reasons && reasons.length) {
239
+ var ul = el('ul')
240
+ reasons.forEach(function (r) { ul.appendChild(el('li', null, r)) })
241
+ add(box, ul)
242
+ }
243
+ if (k && k.timedOut && k.timedOut.length) {
244
+ var tl = el('ul', 'timedout')
245
+ k.timedOut.forEach(function (r) { tl.appendChild(el('li', null, r)) })
246
+ add(box, tl)
122
247
  }
123
- cards.push(card)
124
- return card
248
+ if (c.risks.length) {
249
+ var rb = button('linkbtn', plural(c.risks.length, 'risk') + ' flagged', function () { selectRisks(commits.indexOf(c)) })
250
+ add(box, add(el('p', 'look'), rb))
251
+ }
252
+ if (a && k && k.below) add(box, el('p', 'override mismatch', 'Declared ' + a.level + ', below the computed floor ' + k.floor + (a.override_reason ? '. Reason given: ' + a.override_reason : ' (no reason given).')))
253
+ else if (a && a.override_reason) add(box, el('p', 'override', 'Rated below what the change suggests: ' + a.override_reason))
254
+ return box
125
255
  }
126
256
 
127
- // ---- page ----
128
- function statement(parent, label, text) {
129
- if (text) add(parent, add(el('p', 'statement'), el('span', 'label', label), document.createTextNode(text)))
257
+ // ---- reviews: whether each stage reviewed the commit as it is now ----
258
+ function reviewClass(r) {
259
+ return r.state !== 'seen' || r.verdict === 'issues' ? 'stale' : r.verdict === 'clean' ? 'review-ok' : 'review-none'
130
260
  }
131
- function commitMeta(c) {
132
- return add(el('div', 'commit'), el('span', 'sha', c.sha.slice(0, 10)),
133
- el('span', null, c.author), el('span', null, new Date(c.date).toLocaleString()))
261
+ // A stage's verdict and how current it is, short enough for a rail row.
262
+ function reviewShort(r) {
263
+ var v = r.verdict === 'issues' ? 'issues found' : r.verdict === 'clean' ? 'no issues' : 'reviewed'
264
+ var f = r.state === 'seen' ? 'current' : r.state === 'code-changed' ? 'code changed since review' : 'gloss changed since review'
265
+ return v + ' · ' + f
266
+ }
267
+ // One compact row per stage; each opens the Reviews view in the main pane.
268
+ function reviewStatus(c) {
269
+ var ci = commits.indexOf(c)
270
+ var box = el('div', 'reviews')
271
+ if (!c.reviews.length) return add(box, el('span', 'review-none', 'No reviews recorded'))
272
+ c.reviews.forEach(function (r) {
273
+ var cls = reviewClass(r)
274
+ var b = button('rvrow', null, function () { selectReviews(ci, r.stage) })
275
+ b.setAttribute('data-fid', 'rv:' + ci + ':' + r.stage)
276
+ b.title = r.phrase
277
+ add(b, el('span', 'rvdot ' + (cls === 'review-ok' ? 'ok' : cls === 'stale' ? 'warn' : 'none')), el('span', 'rvstage', r.stage), el('span', 'rvtxt', reviewShort(r)),
278
+ el('span', 'rvn', r.entries.length))
279
+ box.appendChild(b)
280
+ })
281
+ return box
282
+ }
283
+
284
+ // ---- markdown: a small subset, built as DOM nodes (never parsed as HTML) ----
285
+ var ALNUM = /[A-Za-z0-9]/
286
+ // Review text is untrusted and unbounded: no emphasis or link may reach further than this.
287
+ var MAX_SPAN = 500
288
+ function inline(parent, s) {
289
+ var i = 0, buf = ''
290
+ function flush() { if (buf) { parent.appendChild(text(buf)); buf = '' } }
291
+ while (i < s.length) {
292
+ var ch = s.charAt(i), j, m
293
+ if (ch === '`') {
294
+ j = s.indexOf('`', i + 1)
295
+ if (j > i + 1) { flush(); parent.appendChild(el('code', null, s.slice(i + 1, j))); i = j + 1; continue }
296
+ } else if (ch === '*' && s.charAt(i + 1) === '*') {
297
+ j = s.indexOf('**', i + 2)
298
+ if (j > i + 2) { flush(); parent.appendChild(inline(el('strong'), s.slice(i + 2, j))); i = j + 2; continue }
299
+ } else if (ch === '*' && s.charAt(i + 1) && !/[\s*]/.test(s.charAt(i + 1)) && (i === 0 || !ALNUM.test(s.charAt(i - 1)))) {
300
+ // Looks no further than MAX_SPAN: an unclosed marker would otherwise re-read the rest of the text each time (quadratic).
301
+ for (j = i + 2; j < s.length && j < i + MAX_SPAN; j++) {
302
+ if (s.charAt(j) === '*' && !/\s/.test(s.charAt(j - 1)) && !ALNUM.test(s.charAt(j + 1)) && s.charAt(j + 1) !== '*') break
303
+ }
304
+ if (j < s.length && j < i + MAX_SPAN) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
305
+ } else if (ch === '[') {
306
+ m = /^\[([^\]]{1,200})\]\(([^)\s]{1,400})\)/.exec(s.slice(i, i + MAX_SPAN))
307
+ if (m) { flush(); inline(parent, m[1]); parent.appendChild(el('span', 'muted', ' (' + m[2] + ')')); i += m[0].length; continue }
308
+ }
309
+ buf += ch
310
+ i++
311
+ }
312
+ flush()
313
+ return parent
314
+ }
315
+ // Headings # to ####, paragraphs, bullet and numbered lists (nested by
316
+ // indentation), fenced code. Anything else is a paragraph of plain text.
317
+ function markdown(src) {
318
+ var root = el('div', 'md')
319
+ var lines = String(src || '').replace(/\r/g, '').split('\n')
320
+ var para = [], stack = [] // stack of { indent, list, li }
321
+ function endPara() { if (para.length) { root.appendChild(inline(el('p'), para.join(' '))); para = [] } }
322
+ for (var i = 0; i < lines.length; i++) {
323
+ var line = lines[i], m
324
+ if (/^\s*```/.test(line)) {
325
+ endPara(); stack = []
326
+ var code = []
327
+ for (i++; i < lines.length && !/^\s*```/.test(lines[i]); i++) code.push(lines[i])
328
+ root.appendChild(add(el('pre'), el('code', null, code.join('\n'))))
329
+ continue
330
+ }
331
+ if (!line.trim()) { endPara(); continue }
332
+ if ((m = /^(#{1,4})\s+(.*)$/.exec(line))) {
333
+ endPara(); stack = []
334
+ root.appendChild(inline(el('h' + (m[1].length + 2)), m[2]))
335
+ continue
336
+ }
337
+ if ((m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line))) {
338
+ endPara()
339
+ var indent = m[1].length, tag = /^\d/.test(m[2]) ? 'ol' : 'ul'
340
+ while (stack.length && stack[stack.length - 1].indent > indent) stack.pop()
341
+ var top = stack[stack.length - 1]
342
+ if (!top || top.indent < indent || top.list.tagName.toLowerCase() !== tag) {
343
+ if (top && top.indent === indent) stack.pop()
344
+ var list = el(tag)
345
+ if (stack.length && stack[stack.length - 1].li) stack[stack.length - 1].li.appendChild(list); else root.appendChild(list)
346
+ top = { indent: indent, list: list, li: null }
347
+ stack.push(top)
348
+ }
349
+ top.li = inline(el('li'), m[3])
350
+ top.list.appendChild(top.li)
351
+ continue
352
+ }
353
+ if (stack.length && /^\s/.test(line)) { // a wrapped list item
354
+ var cur = stack[stack.length - 1]
355
+ cur.li.appendChild(text(' '))
356
+ inline(cur.li, line.trim())
357
+ continue
358
+ }
359
+ stack = []
360
+ para.push(line.trim())
361
+ }
362
+ endPara()
363
+ return root
134
364
  }
135
365
 
136
- // One commit's groups, unannotated changes, risks and alternatives, with its
137
- // entries in the sidebar.
138
- function commitBody(c, main) {
139
- var tocList = el('ol')
140
- var groupsEl = el('section', 'groups')
141
- c.groups.forEach(function (g) {
142
- groupsEl.appendChild(groupCard(c, g))
143
- var li = el('li')
144
- li.dataset.necessity = g.necessity
145
- li.dataset.key = key(c, g.id)
146
- var a = add(el('a'), color(el('span', 'dot'), g.necessity), el('span', null, g.id))
147
- a.href = '#g-' + key(c, g.id)
148
- a.title = g.intent
149
- a.addEventListener('click', function (ev) { ev.preventDefault(); focusGroup(key(c, g.id), false) })
150
- tocList.appendChild(add(li, a))
366
+ // ---- the Reviews view in the main pane ----
367
+ function verdictOf(findings) {
368
+ var m = /VERDICT:\W*(CLEAN|ISSUES)/i.exec(String(findings).slice(-400))
369
+ return m ? m[1].toUpperCase() : null
370
+ }
371
+ function entryHead(e) {
372
+ var head = el('div', 'ehead')
373
+ add(head, el('strong', null, e.agent + (e.model ? ':' + e.model : '') + (e.mode ? ' (' + e.mode + ')' : '')),
374
+ el('span', 'muted', e.at.slice(0, 16).replace('T', ' ') + ', of ' + e.target))
375
+ var v = verdictOf(e.findings)
376
+ if (v) add(head, el('span', 'chip ' + (v === 'ISSUES' ? 'bad' : 'good'), v === 'ISSUES' ? 'issues found' : 'no issues'))
377
+ return head
378
+ }
379
+ function reviewsPane(c, stage) {
380
+ var pane = el('div')
381
+ add(pane, button('btn small back', '‹ Back', goBack))
382
+ add(pane, commitMeta(c), el('h1', 'title', 'Reviews: ' + c.subject))
383
+ if (!c.reviews.length) return add(pane, el('p', 'muted', 'No reviews recorded for this commit.'))
384
+ c.reviews.forEach(function (r) {
385
+ var sec = el('section', 'rvstagepane')
386
+ sec.id = 'rv-' + r.stage
387
+ add(sec, el('h2', null, r.stage), add(el('p', 'rvsum'), el('span', reviewClass(r), r.phrase)))
388
+ var entries = r.entries.slice().sort(function (a, b) { return a.at < b.at ? 1 : a.at > b.at ? -1 : 0 })
389
+ entries.forEach(function (e, i) {
390
+ if (i === 0) add(sec, add(el('div', 'entry'), entryHead(e), markdown(e.findings)))
391
+ else {
392
+ var d = el('details', 'entry old')
393
+ add(d, add(el('summary'), entryHead(e)), markdown(e.findings))
394
+ add(sec, d)
395
+ }
396
+ })
397
+ add(pane, sec)
151
398
  })
152
- add(main, groupsEl)
153
-
154
- var h = multi ? 'h3' : 'h2'
155
- if (c.unannotated.length) {
156
- var unId = 'unannotated' + (multi ? '-' + c.sha.slice(0, 7) : '')
157
- var un = add(el('section', 'section'), el(h, null, (c.glossed ? 'Unannotated changes' : 'Changes') + ' (' + c.unannotated.length + ')'))
158
- var box = add(el('div', 'unannotated'), el('p', null, c.glossed
159
- ? 'These changes are in the commit but no group explains them.'
160
- : 'This commit has no gloss, so nothing explains these changes.'))
161
- c.unannotated.forEach(function (hk) { box.appendChild(hunkView(c, hk)) })
162
- un.id = unId
163
- add(main, add(un, box))
164
- var ua = add(el('a'), el('span', null, c.glossed ? 'unannotated' : 'unglossed diff'))
165
- ua.href = '#' + unId
166
- tocList.appendChild(add(el('li'), ua))
399
+ return pane
400
+ }
401
+ function goBack() {
402
+ var b = state.back
403
+ if (b && b.kind === 'group') selectGroup(b.ci, b.gid)
404
+ else if (b) selectKind(b.ci, b.kind)
405
+ else selectKind(state.ci, 'overview')
406
+ }
407
+ function selectReviews(ci, stage) {
408
+ if (state.kind !== 'reviews' && state.kind !== 'risks') state.back = { ci: state.ci, kind: state.kind, gid: state.gid }
409
+ state.ci = ci
410
+ state.kind = 'reviews'
411
+ state.gid = null
412
+ state.stage = stage || null
413
+ show(!stage)
414
+ var t = stage && document.getElementById('rv-' + stage)
415
+ if (t) t.scrollIntoView({ block: 'start' }) // deliberately also at load: #reviews-<stage> lands on that stage
416
+ }
417
+ function honestyLabel(c) {
418
+ var honesty = find(c.reviews, function (r) { return r.stage === 'honesty' })
419
+ if (!honesty) return ['stale', 'not reviewed']
420
+ var label = honesty.state !== 'seen' ? 'changed since review'
421
+ : honesty.verdict === 'issues' ? 'honesty: issues found'
422
+ : honesty.verdict === 'clean' ? 'honesty: no issues' : 'honesty-reviewed'
423
+ return [reviewClass(honesty), label]
424
+ }
425
+
426
+ // ---- filters ----
427
+ var contractRank = IMPACTS.indexOf('contract') + 1
428
+ var passes = {
429
+ all: function () { return true },
430
+ focus: function (g, c) { return isFocus(c, g) },
431
+ contract: function (g) { return impactRank(effImpact(g)) >= contractRank },
432
+ low: function (g) { return effImpact(g) === 'local' }
433
+ }
434
+ function visibleGroups(c) {
435
+ var p = passes[state.filter]
436
+ return orderedGroups(c).filter(function (g) { return p(g, c) && !state.hidden.has(g.necessity) })
437
+ }
438
+
439
+ // ---- the order commits are listed in ----
440
+ function commitOrder() {
441
+ // data.commits is oldest first; the rail lists newest first, like git log.
442
+ var idx = commits.map(function (c, i) { return i }).reverse()
443
+ if (multi && state.hot) {
444
+ idx.sort(function (a, b) { return (levelRank(levelOf(commits[b]).level) - levelRank(levelOf(commits[a]).level)) || (b - a) })
445
+ }
446
+ return idx
447
+ }
448
+ // The groups the reader steps through: those of the open commit, in rail order.
449
+ function neighbor(dir) {
450
+ var all = commits[state.ci].order
451
+ var vis = visibleGroups(commits[state.ci]).map(function (g) { return g.id })
452
+ if (!vis.length) return null
453
+ var i = state.kind === 'group' ? all.indexOf(state.gid) : -1
454
+ if (i < 0) return vis[dir > 0 ? 0 : vis.length - 1]
455
+ // The nearest visible group after / before this one in the full order, even if the filter hides this one.
456
+ for (var j = i + dir; j >= 0 && j < all.length; j += dir) if (vis.indexOf(all[j]) >= 0) return all[j]
457
+ return null
458
+ }
459
+ // j/k and Previous/Next: groups when one is open (or on a single commit's
460
+ // overview), commits on a range's commit list.
461
+ function stepTo(dir) {
462
+ if (!multi && state.kind === 'overview' && dir < 0) return
463
+ if (!multi && state.kind === 'group' && dir < 0 && !neighbor(-1)) { selectKind(0, 'overview'); return }
464
+ if (multi && state.kind !== 'group') {
465
+ var order = commitOrder()
466
+ var next = order[order.indexOf(state.ci) + dir]
467
+ if (next !== undefined) selectKind(next, 'overview')
468
+ return
167
469
  }
470
+ var id = neighbor(dir)
471
+ if (id) selectGroup(state.ci, id)
472
+ }
473
+ function resetFilters() { state.filter = 'all'; state.hidden.clear() }
474
+
475
+ // ---- the detail pane ----
476
+ var topEl = el('header', 'top')
477
+ // Served by `git gloss ary`: the branch pickers (src/ary/picker.js, included
478
+ // ahead of this script only then) live in the top bar. Built once, since
479
+ // the bar is rebuilt on every change of filter.
480
+ var aryEl = data.ary ? aryBar(data.ary, el, add) : null
481
+ var railEl = el('aside', 'rail')
482
+ railEl.setAttribute('aria-label', multi ? 'Commits in the range' : 'Groups in review order')
483
+ var mainEl = el('main', 'main')
484
+ var body = add(el('div', multi ? 'body range' : 'body'), railEl, mainEl)
485
+
486
+ // What the author flagged about the commit as a whole: it belongs to the
487
+ // Overview, not under any one group.
488
+ function risksSection(c, pane) {
168
489
  if (c.risks.length) {
169
490
  var rl = el('ul', 'list')
170
491
  c.risks.forEach(function (r) { rl.appendChild(el('li', null, r)) })
171
- add(main, add(el('section', 'section'), el(h, null, 'Risks'), rl))
492
+ var h = el('h2', null, 'Risks the author flagged (' + c.risks.length + ')')
493
+ h.id = 'risks'
494
+ add(pane, h, rl)
172
495
  }
173
496
  if (c.alternatives.length) {
174
497
  var al = el('ul', 'list')
175
498
  c.alternatives.forEach(function (x) {
176
499
  al.appendChild(add(el('li'), el('strong', null, x.option), el('span', 'why', ' — ' + x.rejected_because)))
177
500
  })
178
- add(main, add(el('section', 'section'), el(h, null, 'Alternatives considered'), al))
179
- }
180
- return tocList
181
- }
182
-
183
- function render() {
184
- var layout = el('div', 'layout')
185
- var toc = el('nav', 'toc')
186
- var main = el('main')
187
- add(layout, toc, main)
188
- var allGroups = []
189
- data.commits.forEach(function (c) { allGroups = allGroups.concat(c.groups) })
190
-
191
- // Whether each commit as it is now was reviewed, per stage; each stage's
192
- // reports open on demand. Findings are shown as text.
193
- function reviewsBlock(c, target) {
194
- var box = el('div', 'reviews')
195
- if (!c.reviews.length) { add(target, add(box, el('span', 'review-none', 'No reviews recorded'))); return }
196
- c.reviews.forEach(function (r) {
197
- var d = el('details', 'review')
198
- add(d, add(el('summary'), el('span', r.state === 'seen' && r.verdict !== 'issues' ? (r.verdict === 'clean' ? 'review-ok' : 'review-none') : 'stale', r.phrase)))
199
- r.entries.forEach(function (e) {
200
- add(d, el('div', 'note', r.stage + ' by ' + e.agent + (e.model ? ':' + e.model : '') + (e.mode ? ' (' + e.mode + ')' : '') +
201
- ', ' + e.at.slice(0, 16).replace('T', ' ') + ', of ' + e.target), el('pre', 'findings', e.findings))
202
- })
203
- add(box, d)
204
- })
205
- add(target, box)
501
+ add(pane, el('h2', null, 'Alternatives the author rejected (' + c.alternatives.length + ')'), al)
206
502
  }
503
+ }
504
+ function riskCounts(c) {
505
+ var parts = []
506
+ if (c.risks.length) parts.push(plural(c.risks.length, 'risk'))
507
+ if (c.alternatives.length) parts.push(plural(c.alternatives.length, 'alternative'))
508
+ return parts.join(' · ')
509
+ }
510
+ // The row that opens the risks and alternatives page; none when there are neither.
511
+ function risksRow(ci) {
512
+ var c = commits[ci]
513
+ if (!c.risks.length && !c.alternatives.length) return null
514
+ var b = button('rvrow', null, function () { selectRisks(ci) })
515
+ b.setAttribute('data-fid', 'risks:' + ci)
516
+ return add(b, el('span', 'rvdot none'), el('span', 'rvstage', 'Risks and alternatives'), el('span', 'rvtxt', riskCounts(c)))
517
+ }
518
+ function risksPane(c) {
519
+ var pane = el('div')
520
+ add(pane, button('btn small back', '‹ Back', goBack))
521
+ add(pane, commitMeta(c), el('h1', 'title', 'Risks and alternatives: ' + c.subject))
522
+ risksSection(c, pane)
523
+ return pane
524
+ }
525
+ function selectRisks(ci) {
526
+ if (state.kind !== 'reviews' && state.kind !== 'risks') state.back = { ci: state.ci, kind: state.kind, gid: state.gid }
527
+ state.ci = ci
528
+ state.kind = 'risks'
529
+ state.gid = null
530
+ show(true)
531
+ }
207
532
 
208
- // Header: the commit itself, or the range and its commits.
209
- if (multi) {
210
- var glossed = data.commits.filter(function (c) { return c.glossed }).length
211
- var vetted = data.commits.filter(function (c) {
212
- return c.glossed && c.reviews.some(function (r) { return r.stage === 'honesty' && r.state === 'seen' })
213
- }).length
214
- add(main, add(el('div', 'commit'), el('span', 'sha', data.range),
215
- el('span', null, plural(data.commits.length, 'commit') + ', ' + glossed + ' glossed' + (glossed ? ', ' + vetted + ' honesty-reviewed' : ''))))
216
- add(main, el('h1', null, 'Branch ' + data.range))
217
- var list = el('ol', 'commit-list')
218
- data.commits.forEach(function (c) {
219
- var a = add(el('a'), el('span', 'sha', c.sha.slice(0, 7)), document.createTextNode(' ' + c.subject))
220
- a.href = '#c-' + c.sha.slice(0, 7)
221
- var li = add(el('li'), a)
222
- if (!c.glossed) add(li, el('span', 'stale', 'no gloss'))
223
- else if (c.warnings.length) add(li, el('span', 'stale', 'stale gloss'))
224
- if (c.glossed) {
225
- var honesty = c.reviews.filter(function (r) { return r.stage === 'honesty' })[0]
226
- add(li, !honesty ? el('span', 'stale', 'not reviewed')
227
- : honesty.state !== 'seen' ? el('span', 'stale', 'changed since review')
228
- : honesty.verdict === 'issues' ? el('span', 'stale', 'honesty: issues found')
229
- : el('span', 'review-ok', honesty.verdict === 'clean' ? 'honesty: no issues' : 'honesty-reviewed'))
230
- }
231
- list.appendChild(li)
232
- })
233
- data.merges.forEach(function (m) {
234
- list.appendChild(add(el('li'), el('span', 'sha', m.sha.slice(0, 7)), document.createTextNode(' ' + m.subject),
235
- el('span', 'stale', 'merge, not reviewed')))
236
- })
237
- add(main, list)
238
- } else {
239
- var only = data.commits[0]
240
- add(main, commitMeta(only), el('h1', null, only.subject))
241
- statement(main, 'Task', only.task)
242
- statement(main, 'Summary', only.summary)
243
- only.warnings.forEach(function (w) { add(main, el('div', 'warning', '⚠ ' + w)) })
244
- reviewsBlock(only, main)
245
- }
246
-
247
- // Necessity overview across every commit: bar weighted by changed lines,
248
- // chips filter groups.
249
- var present = data.necessity.filter(function (n) { return allGroups.some(function (g) { return g.necessity === n.name }) })
250
- var total = allGroups.reduce(function (s, g) { return s + g.weight }, 0) || 1
533
+ // Previous / Next / mark read: rebuilt whenever the filters change, since
534
+ // which neighbours exist depends on the filter and the position only (not on
535
+ // read marks).
536
+ function navBar() {
537
+ var next = neighbor(1)
538
+ var nav = el('div', 'nav')
539
+ var pb = button('btn', 'Previous', function () { stepTo(-1) })
540
+ pb.setAttribute('data-fid', 'nav:prev')
541
+ var nb = button('btn', 'Next', function () { stepTo(1) })
542
+ nb.setAttribute('data-fid', 'nav:next')
543
+ pb.disabled = !neighbor(-1) && !(!multi && state.kind === 'group')
544
+ nb.disabled = !next
545
+ var rb = button('btn primary', next ? 'Mark as read and go to next' : 'Mark as read', function () { markRead(true); stepTo(1) })
546
+ rb.setAttribute('data-fid', 'nav:read')
547
+ return add(nav, pb, nb, rb, add(el('span', 'keys'), el('kbd', null, 'j'), text(' '), el('kbd', null, 'k'), text(multi ? ' step · ' : ' step (the overview comes before the first group) · '),
548
+ el('kbd', null, 'r'), text(' mark read · '), el('kbd', null, 'o'), text(' fold hunk')))
549
+ }
550
+ function refreshNav() {
551
+ var old = mainEl.querySelector('.nav')
552
+ if (old) old.parentNode.replaceChild(navBar(), old)
553
+ }
554
+
555
+ // The "Changes" heading with the buttons that fold every hunk of the pane.
556
+ function changesHead(title) {
557
+ var expand = button('btn small', 'Expand all hunks', function () { setCode(true) })
558
+ var collapse = button('btn small', 'Collapse all hunks', function () { setCode(false) })
559
+ expand.setAttribute('data-fid', 'code:expand')
560
+ collapse.setAttribute('data-fid', 'code:collapse')
561
+ return add(el('div', 'chead'), el('h2', null, title), add(el('span'), expand, text(' '), collapse))
562
+ }
563
+
564
+ // On a range, a group or the unannotated changes open inside their commit.
565
+ function backLink(c, pane) {
566
+ if (!multi) return
567
+ var b = button('btn small back', '‹ ' + c.subject, function () { selectKind(commits.indexOf(c), 'overview') })
568
+ b.title = 'Back to the commit'
569
+ add(pane, b)
570
+ }
571
+
572
+ function groupPane(c, g) {
573
+ var pane = el('div')
574
+ var order = c.order
575
+ var hunkN = g.hunks.length
576
+ var files = []
577
+ var perFile = Object.create(null)
578
+ g.hunks.forEach(function (h) {
579
+ if (!perFile[h.file]) { perFile[h.file] = 0; files.push(h.file) }
580
+ perFile[h.file]++
581
+ })
582
+ backLink(c, pane)
583
+ add(pane, commitMeta(c))
584
+ add(pane, el('div', 'crumbs', (multi ? sha7(c) + ' · ' : '') + 'Group ' + (order.indexOf(g.id) + 1) + ' of ' + order.length + ' · ' +
585
+ plural(hunkN, 'hunk') + ' in ' + plural(files.length, 'file')))
586
+ add(pane, el('h1', 'intent', g.intent))
587
+
588
+ var tags = el('div', 'tags')
589
+ add(tags, el('span', 'chip', g.necessity))
590
+ var level = effImpact(g)
591
+ var imp = add(el('span', 'imp' + (g.impact ? '' : ' derived')), meter(level), text(impactWord(g)))
592
+ if (level) imp.style.setProperty('--m', 'var(--impact-' + level + ')')
593
+ if (!g.impact && level) imp.title = 'Derived from the diff, not declared in the gloss'
594
+ add(tags, imp, el('span', 'chip mono', g.id))
595
+ if (g.serves[0] !== 'task') add(tags, add(el('span', 'chip'), text('serves '), linkList(c, g.serves)))
596
+ else add(tags, el('span', 'chip', 'serves task'))
597
+ if (isFocus(c, g)) add(tags, el('span', 'chip', 'look here first'))
598
+ if (g.stale) add(tags, el('span', 'stale', g.stale + ' of ' + plural(g.total, 'hunk') + ' changed or gone'))
599
+ add(tags, stats(g.added, g.removed))
600
+ add(pane, tags)
601
+ if (g.impact_reason) add(pane, el('p', 'reason', g.impact_reason))
602
+ warnings(c, pane)
603
+
604
+ var cards = el('div', 'cards')
605
+ function card(label, value) {
606
+ if (value) add(cards, add(el('div', 'card'), el('div', 'eyebrow', label), el('p', null, value)))
607
+ }
608
+ card('If removed', g.removal_impact)
609
+ card('Evidence', g.evidence)
610
+ card('Notes', g.notes)
611
+ add(pane, cards)
612
+
613
+ add(pane, changesHead('Changes'))
614
+ if (files.length) {
615
+ var fl = el('div', 'files')
616
+ files.forEach(function (f) { add(fl, add(el('div', 'frow'), el('span', 'mono', f), el('span', 'muted', plural(perFile[f], 'hunk')))) })
617
+ add(pane, fl)
618
+ }
619
+ g.hunks.forEach(function (h) { pane.appendChild(hunkView(c, h, g.id)) })
620
+ add(pane, navBar())
621
+ return pane
622
+ }
623
+
624
+ function unannotatedBox(c) {
625
+ var box = add(el('div', 'unannotated'), el('p', null, c.glossed
626
+ ? 'These changes are in the commit but no group explains them.'
627
+ : 'This commit has no gloss, so nothing explains these changes.'))
628
+ c.unannotated.forEach(function (h) { box.appendChild(hunkView(c, h, 'un')) })
629
+ return box
630
+ }
631
+ function unannotatedPane(c) {
632
+ var pane = el('div')
633
+ backLink(c, pane)
634
+ add(pane, commitMeta(c), el('h1', 'title', (c.glossed ? 'Unannotated changes' : 'Changes') + ' (' + c.unannotated.length + ')'))
635
+ warnings(c, pane)
636
+ add(pane, changesHead('Changes'), unannotatedBox(c))
637
+ return pane
638
+ }
639
+
640
+ // Share of changed lines per necessity, and the groups of the commit.
641
+ function necessityBar(c) {
642
+ var total = c.groups.reduce(function (s, g) { return s + g.weight }, 0) || 1
251
643
  var bar = el('div', 'bar')
252
- var chips = el('div', 'chips')
253
- present.forEach(function (n) {
254
- var groups = allGroups.filter(function (g) { return g.necessity === n.name })
255
- var weight = groups.reduce(function (s, g) { return s + g.weight }, 0)
256
- var seg = color(el('span'), n.name)
644
+ var legend = el('div', 'legend')
645
+ data.necessity.forEach(function (n) {
646
+ var gs = c.groups.filter(function (g) { return g.necessity === n.name })
647
+ if (!gs.length) return
648
+ var weight = gs.reduce(function (s, g) { return s + g.weight }, 0)
649
+ var seg = el('span')
650
+ seg.style.setProperty('--c', 'var(--n-' + n.name + ')')
257
651
  seg.style.width = (100 * weight / total) + '%'
258
652
  seg.title = n.name + ': ' + Math.round(100 * weight / total) + '% of changed lines'
259
653
  bar.appendChild(seg)
654
+ var item = el('span', null, n.name + ' ' + gs.length)
655
+ item.title = n.help
656
+ var dot = el('span', 'dot')
657
+ dot.style.setProperty('--c', 'var(--n-' + n.name + ')')
658
+ item.insertBefore(dot, item.firstChild)
659
+ legend.appendChild(item)
660
+ })
661
+ return [bar, legend]
662
+ }
260
663
 
261
- var chip = color(el('button', 'chip'), n.name)
262
- chip.type = 'button'
263
- chip.title = n.help + ' (click to show or hide)'
264
- chip.setAttribute('aria-pressed', 'true')
265
- add(chip, el('span', 'dot'), el('span', null, n.name), el('span', 'count', groups.length))
266
- chip.dataset.necessity = n.name
267
- chip.addEventListener('click', function () {
268
- if (hidden.has(n.name)) hidden.delete(n.name); else hidden.add(n.name)
269
- chip.setAttribute('aria-pressed', String(!hidden.has(n.name)))
270
- applyFilter()
271
- })
272
- chips.appendChild(chip)
664
+ // The groups the author says to read first, as links; nothing when none are declared.
665
+ function focusSection(c, ci, pane) {
666
+ var focus = c.attention && c.attention.focus ? c.attention.focus : []
667
+ var groups = focus.map(function (id) { return groupOf(c, id) }).filter(Boolean)
668
+ if (!groups.length) return
669
+ var list = el('div', 'focuscards')
670
+ groups.forEach(function (g) {
671
+ var b = button('focuscard', null, function () { selectGroup(ci, g.id) })
672
+ var level = effImpact(g)
673
+ var imp = add(el('span', 'imp'), meter(level), text(impactWord(g)))
674
+ if (level) imp.style.setProperty('--m', 'var(--impact-' + level + ')')
675
+ add(b, el('span', 'fctitle', g.intent), add(el('span', 'fcmeta'), imp, el('span', 'chip', g.necessity)))
676
+ if (g.impact_reason) add(b, el('span', 'fcreason', g.impact_reason))
677
+ list.appendChild(b)
273
678
  })
274
- add(main, add(el('section', 'overview'), bar, chips,
275
- el('div', 'note', 'Bar shows each category’s share of changed lines' + (multi ? ' across the range' : '') +
276
- '. Click a category to hide or show its groups.')))
277
-
278
- // Toolbar
279
- var shown = el('span', 'shown')
280
- var expand = el('button', 'btn', 'Expand all code')
281
- var collapse = el('button', 'btn', 'Collapse all')
282
- expand.type = collapse.type = 'button'
283
- expand.addEventListener('click', function () { setAll(true) })
284
- collapse.addEventListener('click', function () { setAll(false) })
285
- add(main, add(el('div', 'toolbar'), shown, add(el('span'), expand, document.createTextNode(' '), collapse)))
286
-
287
- // Commits, each with its groups; the sidebar groups them by commit.
288
- data.commits.forEach(function (c) {
289
- var target = main
290
- if (multi) {
291
- target = el('section', 'commit-section')
292
- target.id = 'c-' + c.sha.slice(0, 7)
293
- add(target, commitMeta(c), el('h2', null, c.subject))
294
- statement(target, 'Task', c.task)
295
- statement(target, 'Summary', c.summary)
296
- c.warnings.forEach(function (w) { add(target, el('div', 'warning', '⚠ ' + w)) })
297
- reviewsBlock(c, target)
298
- add(main, target)
679
+ add(pane, el('h2', null, 'Look here first'), list)
680
+ }
681
+
682
+ function overviewPane(c) {
683
+ var ci = commits.indexOf(c)
684
+ var pane = el('div')
685
+ if (multi) add(pane, el('div', 'eyebrow', 'Commit ' + (ci + 1) + ' of ' + commits.length + ' · ' + new Date(c.date).toLocaleDateString()))
686
+ add(pane, el('h1', 'title', c.subject), commitMeta(c))
687
+ if (!c.glossed) {
688
+ var w = add(el('section', 'warnbox'), el('div', 't', 'No gloss on this commit'),
689
+ el('p', null, 'Its whole diff is unannotated, so there is no attention level, no groups and no reasons to read.'),
690
+ el('p', 'cmd', 'git gloss add ' + sha7(c)))
691
+ add(pane, w)
692
+ add(pane, changesHead('Changes (' + c.unannotated.length + ')'), unannotatedBox(c))
693
+ return pane
694
+ }
695
+ warnings(c, pane)
696
+ add(pane, attentionCard(c))
697
+ statement(pane, 'Task', c.task)
698
+ statement(pane, 'Summary', c.summary)
699
+ focusSection(c, ci, pane)
700
+ if (c.groups.length) {
701
+ var nb = necessityBar(c)
702
+ add(pane, el('h2', null, plural(c.groups.length, 'group')), nb[0], nb[1])
703
+ if (multi) groupList(ci, pane, true)
704
+ else {
705
+ var chips = el('div', 'chips')
706
+ orderedGroups(c).forEach(function (g) {
707
+ var b = button('chip link', null, function () { selectGroup(ci, g.id) })
708
+ add(b, el('b', null, g.id), text(' '), el('span', null, g.necessity))
709
+ chips.appendChild(b)
710
+ })
711
+ add(pane, chips)
299
712
  }
300
- var tocList = commitBody(c, target)
301
- var heading = multi ? c.sha.slice(0, 7) + ' ' + c.subject : 'Groups'
302
- var tocHead = el('h2', multi ? 'commit-head' : null, heading)
303
- if (multi) tocHead.title = c.subject
304
- add(toc, tocHead, tocList)
305
- })
713
+ }
714
+ if (multi) {
715
+ var rr = risksRow(ci)
716
+ if (rr) add(pane, el('h2', null, 'Risks and alternatives'), rr)
717
+ add(pane, el('h2', null, 'Reviews'), reviewStatus(c))
718
+ }
719
+ return pane
720
+ }
306
721
 
722
+ function renderPane(scroll) {
723
+ var c = commits[state.ci]
724
+ var g = state.kind === 'group' ? groupOf(c, state.gid) : null
725
+ var pane = state.kind === 'reviews' ? reviewsPane(c, state.stage) : state.kind === 'risks' ? risksPane(c) : g ? groupPane(c, g) : state.kind === 'unannotated' && c.unannotated.length ? unannotatedPane(c) : overviewPane(c)
726
+ keepFocus(function () {
727
+ mainEl.textContent = ''
728
+ add(mainEl, pane)
729
+ add(mainEl, footer())
730
+ syncFold()
731
+ }, true)
732
+ if (scroll && !booting) {
733
+ if (window.matchMedia && window.matchMedia('(min-width: 900px)').matches) window.scrollTo(0, 0)
734
+ else mainEl.scrollIntoView({ block: 'start' })
735
+ }
736
+ }
737
+
738
+ function footer() {
307
739
  var foot = el('footer')
308
- add(foot, document.createTextNode('Keys: '), el('kbd', null, 'j'), document.createTextNode(' / '), el('kbd', null, 'k'),
309
- document.createTextNode(' next / previous group · '), el('kbd', null, 'o'), document.createTextNode(' show or hide code · '),
310
- el('kbd', null, 'e'), document.createTextNode(' expand all · '), el('kbd', null, 'c'),
311
- document.createTextNode(' collapse all.'))
740
+ add(foot, text('Keys: '), el('kbd', null, 'j'), text(' / '), el('kbd', null, 'k'),
741
+ text(multi ? ' next / previous group · ' : ' next / previous group (the overview comes before the first group) · '), el('kbd', null, 'o'), text(' fold or unfold the focused hunk (else the first) · '),
742
+ el('kbd', null, 'e'), text(' expand all hunks · '), el('kbd', null, 'c'), text(' collapse all hunks · '),
743
+ el('kbd', null, 'r'), text(' mark group read or unread.'))
312
744
  add(foot, el('p', null, 'A snapshot generated by git-gloss ' + new Date(data.generated).toLocaleString() +
313
745
  '. It does not update if the gloss or commit changes; regenerate with git gloss html ' +
314
- (data.range || data.commits[0].sha.slice(0, 10)) + '.'))
315
- add(main, foot)
316
-
317
- app.textContent = ''
318
- app.appendChild(layout)
319
-
320
- function applyFilter() {
321
- var visible = 0
322
- cards.forEach(function (c) {
323
- var off = hidden.has(c.dataset.necessity)
324
- c.classList.toggle('hidden', off)
325
- if (!off) visible++
746
+ (data.range || commits[0].sha.slice(0, 10)) + '.'))
747
+ return foot
748
+ }
749
+
750
+ // ---- the top bar ----
751
+ function filterSeg(c) {
752
+ var seg = el('div', 'seg')
753
+ seg.setAttribute('role', 'group')
754
+ seg.setAttribute('aria-label', 'Show groups')
755
+ FILTERS.forEach(function (f) {
756
+ var n = c.groups.filter(function (g) { return passes[f[0]](g, c) }).length
757
+ var b = add(button(null, f[1]), el('span', 'n', n))
758
+ b.setAttribute('aria-pressed', String(state.filter === f[0]))
759
+ b.setAttribute('data-fid', 'seg:' + f[0])
760
+ b.addEventListener('click', function () { state.filter = f[0]; renderChrome() })
761
+ seg.appendChild(b)
762
+ })
763
+ return seg
764
+ }
765
+ function renderTop() {
766
+ var c = commits[state.ci]
767
+ topEl.textContent = ''
768
+ add(topEl, el('span', 'brand', 'git gloss'), aryEl)
769
+ if (multi) {
770
+ var glossed = commits.filter(function (x) { return x.glossed }).length
771
+ var vetted = commits.filter(function (x) {
772
+ return x.glossed && x.reviews.some(function (r) { return r.stage === 'honesty' && r.state === 'seen' })
773
+ }).length
774
+ add(topEl, el('span', 'sha', data.range || 'commits'),
775
+ el('span', 'subject', plural(commits.length, 'commit') + ', ' + glossed + ' glossed' + (glossed ? ', ' + vetted + ' honesty-reviewed' : '')))
776
+ var order = el('div', 'seg')
777
+ order.setAttribute('role', 'group')
778
+ order.setAttribute('aria-label', 'Order commits')
779
+ ;[[true, 'Hottest first'], [false, 'History order']].forEach(function (o) {
780
+ var b = button(null, o[1], function () { state.hot = o[0]; renderChrome() })
781
+ b.setAttribute('aria-pressed', String(state.hot === o[0]))
782
+ b.setAttribute('data-fid', 'order:' + o[1])
783
+ order.appendChild(b)
784
+ })
785
+ add(topEl, order)
786
+ } else {
787
+ var subject = el('span', 'subject', c.subject)
788
+ subject.title = c.subject
789
+ add(topEl, el('span', 'sha', c.sha.slice(0, 7)), subject)
790
+ }
791
+ var counts = readCounts()
792
+ var fill = el('i')
793
+ fill.style.width = (counts.total ? Math.round(100 * counts.read / counts.total) : 0) + '%'
794
+ var prog = add(el('div', 'prog'), el('span', null, counts.read + ' of ' + counts.total + ' read'), add(el('span', 'track'), fill))
795
+ prog.setAttribute('aria-live', 'polite')
796
+ if (!multi) add(topEl, filterSeg(c))
797
+ add(topEl, prog)
798
+ }
799
+
800
+ // ---- the rail ----
801
+ function groupRow(ci, g) {
802
+ var c = commits[ci]
803
+ var k = key(c, g.id)
804
+ var sel = state.ci === ci && state.kind === 'group' && state.gid === g.id
805
+ var done = !!readSet(c)[g.id]
806
+ var row = el('div', 'row' + (sel ? ' sel' : '') + (done ? ' done' : ''))
807
+ var chk = button('check', null, function () { setRead(c, g.id, !readSet(c)[g.id]); renderChrome() })
808
+ chk.setAttribute('aria-pressed', String(done))
809
+ chk.setAttribute('aria-label', (done ? 'Mark unread: ' : 'Mark read: ') + g.id)
810
+ chk.setAttribute('data-fid', 'chk:' + k)
811
+ add(chk, el('span', null, done ? '✓' : ''))
812
+ var pick = button('pick', null, function () { selectGroup(ci, g.id) })
813
+ if (sel) pick.setAttribute('aria-current', 'true')
814
+ pick.setAttribute('data-fid', 'pick:' + k)
815
+ var sub = add(el('span', 'rsub'), el('span', 'mono', g.id), text(' · ' + g.necessity + ' · ' + impactWord(g)))
816
+ add(pick, meter(effImpact(g)), add(el('span', 'rtxt'), el('span', 'rtitle', g.intent), sub))
817
+ if (isFocus(c, g)) {
818
+ var flag = el('span', 'flag', 'first')
819
+ var lv = levelOf(c).level
820
+ if (lv) flag.style.setProperty('--lv', 'var(--attention-' + lv + ')')
821
+ add(pick, flag)
822
+ }
823
+ return add(row, chk, pick)
824
+ }
825
+ function extraRow(ci, kind, label, sub) {
826
+ var sel = state.ci === ci && state.kind === kind
827
+ var row = el('div', sel ? 'row sel' : 'row')
828
+ var pick = button('pick', null, function () { selectKind(ci, kind) })
829
+ if (sel) pick.setAttribute('aria-current', 'true')
830
+ pick.setAttribute('data-fid', 'extra:' + kind + ':' + ci)
831
+ add(pick, add(el('span', 'rtxt'), el('span', 'rtitle', label), el('span', 'rsub', sub)))
832
+ return add(row, pick)
833
+ }
834
+
835
+ // The group list of one commit: necessity filter chips, then the rows.
836
+ function groupList(ci, parent, withSeg) {
837
+ var c = commits[ci]
838
+ if (withSeg) add(parent, add(el('div', 'filters'), filterSeg(c)))
839
+ var present = data.necessity.filter(function (n) { return c.groups.some(function (g) { return g.necessity === n.name }) })
840
+ if (present.length > 1 || present.some(function (n) { return state.hidden.has(n.name) })) {
841
+ var necs = el('div', 'necs')
842
+ necs.setAttribute('role', 'group')
843
+ necs.setAttribute('aria-label', 'Show necessity')
844
+ present.forEach(function (n) {
845
+ var count = c.groups.filter(function (g) { return g.necessity === n.name }).length
846
+ var chip = add(button('chip', null, function () {
847
+ if (state.hidden.has(n.name)) state.hidden.delete(n.name); else state.hidden.add(n.name)
848
+ renderChrome()
849
+ }), el('span', null, n.name), el('span', 'count', count))
850
+ chip.title = n.help + ' (click to show or hide)'
851
+ chip.setAttribute('aria-pressed', String(!state.hidden.has(n.name)))
852
+ chip.setAttribute('data-fid', 'nec:' + n.name)
853
+ necs.appendChild(chip)
326
854
  })
327
- toc.querySelectorAll('li[data-necessity]').forEach(function (li) { li.classList.toggle('hidden', hidden.has(li.dataset.necessity)) })
328
- shown.textContent = visible === cards.length
329
- ? plural(cards.length, 'group')
330
- : 'Showing ' + visible + ' of ' + plural(cards.length, 'group')
331
- }
332
- applyFilter()
333
- showNecessity = function (n) {
334
- hidden.delete(n)
335
- chips.querySelectorAll('.chip').forEach(function (c) { if (c.dataset.necessity === n) c.setAttribute('aria-pressed', 'true') })
336
- applyFilter()
337
- }
338
- }
339
- var showNecessity = function () {}
340
-
341
- function setAll(open) {
342
- cards.forEach(function (c) { if (c.setOpen && !c.classList.contains('hidden')) c.setOpen(open) })
343
- }
344
-
345
- var current = -1
346
- function focusGroup(k, openCode, instant) {
347
- var i = cards.findIndex(function (c) { return c.id === 'g-' + k })
348
- if (i < 0) return
349
- var card = cards[i]
350
- // Following a link to a filtered-out group turns its category back on.
351
- if (card.classList.contains('hidden')) showNecessity(card.dataset.necessity)
352
- cards.forEach(function (c) { c.classList.remove('focused') })
353
- card.classList.add('focused')
354
- current = i
355
- if (openCode && card.setOpen) card.setOpen(true)
356
- card.scrollIntoView({ block: 'start', behavior: instant ? 'auto' : 'smooth' })
357
- document.querySelectorAll('.toc li').forEach(function (li) { li.classList.toggle('active', li.dataset.key === k) })
358
- history.replaceState(null, '', '#g-' + k)
359
- }
360
- function step(dir) {
361
- var visible = cards.filter(function (c) { return !c.classList.contains('hidden') })
362
- if (!visible.length) return
363
- var pos = visible.indexOf(cards[current])
364
- var next = visible[Math.min(visible.length - 1, Math.max(0, pos + dir))] || visible[0]
365
- focusGroup(next.id.slice(2), false)
855
+ add(parent, necs)
856
+ }
857
+ var rows = el('div', 'rows')
858
+ var vis = visibleGroups(c)
859
+ vis.forEach(function (g) { rows.appendChild(groupRow(ci, g)) })
860
+ if (!vis.length && c.groups.length) {
861
+ var none = el('div', 'emptyrows', 'No groups match this filter. ')
862
+ add(none, button('btn small', 'Show all', function () { resetFilters(); renderChrome() }))
863
+ add(parent, none)
864
+ }
865
+ if (c.unannotated.length && c.glossed) rows.appendChild(extraRow(ci, 'unannotated', 'Unannotated changes (' + c.unannotated.length + ')', 'in the commit, explained by no group'))
866
+ add(parent, rows)
867
+ }
868
+
869
+ function commitRow(ci) {
870
+ var c = commits[ci]
871
+ var lv = levelOf(c)
872
+ var sel = state.ci === ci
873
+ var wrap = el('div', sel ? 'crow sel' : 'crow')
874
+ var pick = button('pick', null, function () { selectKind(ci, 'overview') })
875
+ if (sel) pick.setAttribute('aria-current', 'true')
876
+ pick.setAttribute('data-fid', 'commit:' + c.sha)
877
+ var level = el('span', 'lv')
878
+ if (lv.level) {
879
+ level.style.setProperty('--lv', 'var(--attention-' + lv.level + ')')
880
+ add(level, gauge(lv.level, true), el('span', null, lv.level.toUpperCase()))
881
+ } else add(level, el('span', null, c.glossed ? 'UNRATED' : 'NO GLOSS'))
882
+ var meta
883
+ if (c.glossed) {
884
+ var rs = readSet(c)
885
+ var done = c.groups.filter(function (g) { return rs[g.id] }).length
886
+ meta = plural(c.groups.length, 'group') + ' · ' + plural(fileCount(c), 'file') + (lv.source === 'unrated' ? '' : ' · ' + lv.source) + ' · ' + done + '/' + c.groups.length + ' read'
887
+ } else {
888
+ var added = 0, removed = 0
889
+ c.unannotated.forEach(function (h) { added += h.added; removed += h.removed })
890
+ meta = plural(fileCount(c), 'file') + ' · +' + added + ' −' + removed
891
+ }
892
+ var metaEl = el('span', 'cmeta', meta)
893
+ if (c.glossed) {
894
+ if (c.warnings.length) add(metaEl, text(' · '), el('span', 'stale', 'stale gloss'))
895
+ var hl = honestyLabel(c)
896
+ add(metaEl, text(' · '), el('span', hl[0], hl[1]))
897
+ } else add(metaEl, text(' · '), el('span', 'stale', 'no gloss'))
898
+ add(pick, add(el('span', 'rtop'), el('span', 'sha', sha7(c)), el('span', 'sha', new Date(c.date).toLocaleDateString()), level),
899
+ el('span', 'csubject', c.subject), metaEl)
900
+ add(wrap, pick)
901
+ return wrap
902
+ }
903
+
904
+ function railSummary() {
905
+ var counts = {}
906
+ var strip = el('div', 'strip')
907
+ strip.setAttribute('aria-hidden', 'true')
908
+ var order = commitOrder()
909
+ order.forEach(function (ci) {
910
+ var lv = levelOf(commits[ci])
911
+ var name = lv.level || (commits[ci].glossed ? 'unrated' : 'no gloss')
912
+ counts[name] = (counts[name] || 0) + 1
913
+ var seg = el('i', lv.level ? null : 'none')
914
+ if (lv.level) seg.style.setProperty('--lv', 'var(--attention-' + lv.level + ')')
915
+ strip.appendChild(seg)
916
+ })
917
+ var line = el('div', 'line')
918
+ var first = true
919
+ LEVELS.slice().reverse().concat(['unrated', 'no gloss']).forEach(function (name) {
920
+ if (!counts[name]) return
921
+ if (!first) line.appendChild(text(' · '))
922
+ first = false
923
+ add(line, el('b', null, counts[name]), text(' ' + (name === 'no gloss' ? 'without a gloss' : name)))
924
+ })
925
+ var box = add(el('div', 'sum'), el('div', 'eyebrow', 'Range at a glance'), strip, line)
926
+ var top = commits[order[0]]
927
+ if (levelOf(top).level) {
928
+ var link = el('a', null, sha7(top) + ' ' + top.subject)
929
+ link.href = '#c-' + sha7(top)
930
+ link.addEventListener('click', function (ev) { ev.preventDefault(); selectKind(order[0], 'overview') })
931
+ add(box, add(el('div', 'line'), text(state.hot ? 'Read first: ' : 'Hottest: '), link))
932
+ }
933
+ return box
934
+ }
935
+
936
+ function renderRail() {
937
+ railEl.textContent = ''
938
+ if (multi) {
939
+ add(railEl, railSummary(), add(el('div', 'railhead'), el('div', 'eyebrow', state.hot ? 'Hottest first, then newest' : 'Newest first')))
940
+ var list = el('div', 'commits')
941
+ commitOrder().forEach(function (ci) { list.appendChild(commitRow(ci)) })
942
+ add(railEl, list)
943
+ if (data.merges.length) {
944
+ var ml = add(el('div', 'mergelist'), el('div', 'eyebrow', 'Merges, not reviewed'))
945
+ data.merges.forEach(function (m) { ml.appendChild(add(el('div'), el('span', 'mono', m.sha.slice(0, 7)), text(' ' + m.subject))) })
946
+ add(railEl, ml)
947
+ }
948
+ return
949
+ }
950
+ var c = commits[0]
951
+ var ov = el('div', 'overviewrow')
952
+ var ovRow = extraRow(0, 'overview', 'Overview', 'task, summary, where to start')
953
+ var lvl = levelOf(c).level
954
+ if (lvl) {
955
+ var chip = add(el('span', 'lv'), gauge(lvl, true), el('span', null, lvl.toUpperCase()))
956
+ chip.style.setProperty('--lv', 'var(--attention-' + lvl + ')')
957
+ chip.title = 'Attention: ' + lvl + (c.attention ? ' (declared)' : ' (derived)')
958
+ ovRow.querySelector('.pick').appendChild(chip)
959
+ }
960
+ add(ov, ovRow)
961
+ add(railEl, ov)
962
+ add(railEl, add(el('div', 'railhead'), el('div', 'eyebrow', 'Review order · look first, then by impact')))
963
+ groupList(0, railEl)
964
+ var rr = risksRow(0)
965
+ if (rr) add(railEl, add(el('div', 'railsec'), el('div', 'eyebrow', 'Risks and alternatives'), rr))
966
+ add(railEl, add(el('div', 'railfoot'), el('div', 'eyebrow', 'Reviews'), reviewStatus(c)))
967
+ }
968
+
969
+ // Run a re-render and give keyboard focus back to the control that had it:
970
+ // the one with the same data-fid, or if that is gone or disabled (Next on the
971
+ // last group) the enabled button that is left in the same bar.
972
+ function keepFocus(render, pane) {
973
+ var active = document.activeElement
974
+ var fid = active && active.getAttribute ? active.getAttribute('data-fid') : null
975
+ var inPane = !!(pane && active && active !== document.body && mainEl.contains(active))
976
+ render()
977
+ var target = null
978
+ if (fid) {
979
+ target = app.querySelector('[data-fid="' + CSS.escape(fid) + '"]')
980
+ if (!target || target.disabled) {
981
+ var bar = fid.indexOf('nav:') === 0 ? '.nav' : fid.indexOf('code:') === 0 ? '.chead' : null
982
+ target = bar ? mainEl.querySelector(bar + ' button:not(:disabled)') : null
983
+ }
984
+ }
985
+ // A link or card in the pane that opened another pane: focus moves to the new pane's heading.
986
+ if (!target && inPane) {
987
+ target = mainEl.querySelector('h1')
988
+ if (target) target.setAttribute('tabindex', '-1')
989
+ }
990
+ if (target) target.focus({ preventScroll: true })
991
+ }
992
+
993
+ // Re-render the rail and top bar (and what depends on the filters in the
994
+ // pane), keeping keyboard focus on the same control.
995
+ function renderChrome() {
996
+ keepFocus(function () {
997
+ renderTop()
998
+ renderRail()
999
+ if (multi && state.kind === 'overview') renderPane(false)
1000
+ else refreshNav()
1001
+ })
1002
+ }
1003
+
1004
+ // ---- selection, kept in the hash so links and reloads land on it ----
1005
+ function hashFor() {
1006
+ var c = commits[state.ci]
1007
+ if (state.kind === 'group') return '#g-' + key(c, state.gid)
1008
+ if (state.kind === 'reviews') return '#reviews' + (multi ? '-' + sha7(c) : '') + (state.stage ? '-' + encodeURIComponent(state.stage) : '')
1009
+ if (state.kind === 'risks') return '#risks' + (multi ? '-' + sha7(c) : '')
1010
+ if (state.kind === 'unannotated') return '#unannotated' + (multi ? '-' + sha7(c) : '')
1011
+ return multi ? '#c-' + sha7(c) : '#overview'
1012
+ }
1013
+ function remember() {
1014
+ try { history.replaceState(null, '', hashFor()) } catch (e) { /* not allowed in this context */ }
1015
+ }
1016
+ function show(scroll) {
1017
+ remember()
1018
+ renderChrome()
1019
+ renderPane(scroll)
1020
+ }
1021
+ function selectKind(ci, kind) {
1022
+ state.ci = ci
1023
+ state.kind = kind
1024
+ state.gid = null
1025
+ show(true)
1026
+ }
1027
+ function selectGroup(ci, gid) {
1028
+ var c = commits[ci]
1029
+ var g = groupOf(c, gid)
1030
+ if (!g) return
1031
+ // Following a link to a group the filters hide turns them off.
1032
+ if (visibleGroups(c).indexOf(g) < 0) resetFilters()
1033
+ state.ci = ci
1034
+ state.kind = 'group'
1035
+ state.gid = gid
1036
+ show(true)
1037
+ }
1038
+ function applyHash() {
1039
+ var h
1040
+ try { h = decodeURIComponent(location.hash.slice(1)) } catch (e) { return false }
1041
+ var i, j, c
1042
+ if (h.indexOf('g-') === 0) {
1043
+ for (i = 0; i < commits.length; i++) {
1044
+ for (j = 0; j < commits[i].groups.length; j++) {
1045
+ if (key(commits[i], commits[i].groups[j].id) === h.slice(2)) { selectGroup(i, commits[i].groups[j].id); return true }
1046
+ }
1047
+ }
1048
+ } else if (h === 'overview') { selectKind(0, 'overview'); return true }
1049
+ else if (h === 'risks' && !multi) { selectRisks(0); return true }
1050
+ else if (h.indexOf('risks-') === 0 && multi) {
1051
+ for (i = 0; i < commits.length; i++) if (sha7(commits[i]) === h.slice(6)) { selectRisks(i); return true }
1052
+ }
1053
+ else if (h.indexOf('c-') === 0 || h.indexOf('unannotated-') === 0 || h === 'unannotated') {
1054
+ var sha = h.slice(h.indexOf('-') + 1)
1055
+ for (i = 0; i < commits.length; i++) {
1056
+ c = commits[i]
1057
+ if ((h === 'unannotated' && i === 0) || (h !== 'unannotated' && sha7(c) === sha)) {
1058
+ selectKind(i, h.indexOf('c-') === 0 ? 'overview' : 'unannotated')
1059
+ return true
1060
+ }
1061
+ }
1062
+ } else if (h === 'reviews' || h.indexOf('reviews-') === 0) {
1063
+ var rest = h.slice(8)
1064
+ if (!multi) { selectReviews(0, rest || null); return true }
1065
+ for (i = 0; i < commits.length; i++) {
1066
+ if (sha7(commits[i]) === rest.slice(0, 7)) { selectReviews(i, rest.slice(8) || null); return true }
1067
+ }
1068
+ }
1069
+ return false
1070
+ }
1071
+
1072
+ // ---- actions ----
1073
+ function currentGroup() {
1074
+ return state.kind === 'group' ? groupOf(commits[state.ci], state.gid) : null
1075
+ }
1076
+ function markRead(on) {
1077
+ var g = currentGroup()
1078
+ if (!g) return
1079
+ setRead(commits[state.ci], g.id, on)
1080
+ renderChrome()
366
1081
  }
367
1082
 
368
1083
  document.addEventListener('keydown', function (ev) {
369
1084
  if (ev.metaKey || ev.ctrlKey || ev.altKey) return
370
1085
  var t = ev.target
371
1086
  if (t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)) return
372
- if (ev.key === 'j') step(1)
373
- else if (ev.key === 'k') step(-1)
374
- else if (ev.key === 'o' && cards[current] && cards[current].setOpen) cards[current].setOpen(!cards[current].isOpen())
375
- else if (ev.key === 'e') setAll(true)
376
- else if (ev.key === 'c') setAll(false)
1087
+ if (ev.key === 'j') stepTo(1)
1088
+ else if (ev.key === 'k') stepTo(-1)
1089
+ else if (ev.key === 'o') {
1090
+ var f = document.activeElement
1091
+ var w = f && f.closest ? f.closest('.hunk') : null
1092
+ var tg = (w && w.querySelector('.htoggle')) || mainEl.querySelector('.htoggle')
1093
+ if (tg) { setHunkOpen(tg.closest('.hunk'), tg.getAttribute('aria-expanded') !== 'true'); syncFold() }
1094
+ }
1095
+ else if (ev.key === 'e') setCode(true)
1096
+ else if (ev.key === 'c') setCode(false)
1097
+ else if (ev.key === 'r') {
1098
+ var g = currentGroup()
1099
+ if (g) markRead(!readSet(commits[state.ci])[g.id])
1100
+ }
377
1101
  })
1102
+ window.addEventListener('hashchange', function () { applyHash() })
378
1103
 
379
- render()
380
- if (location.hash.indexOf('#g-') === 0) {
381
- try { focusGroup(decodeURIComponent(location.hash.slice(3)), true, true) } catch (e) { /* malformed hash */ }
1104
+ // ---- start ----
1105
+ var desk = add(el('div', 'desk'), topEl, body)
1106
+ // The rail sticks below the top bar, whose height varies (the pickers wrap).
1107
+ function measureTop() { desk.style.setProperty('--top-h', topEl.offsetHeight + 'px') }
1108
+ app.textContent = ''
1109
+ app.appendChild(desk)
1110
+ measureTop()
1111
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(measureTop).observe(topEl)
1112
+ if (multi) {
1113
+ var first = commitOrder()[0]
1114
+ state.ci = first
1115
+ state.kind = 'overview'
382
1116
  }
1117
+ if (!applyHash()) { renderChrome(); renderPane(false); remember() }
1118
+ booting = false
383
1119
  })()