@ok-ggwp/git-gloss 0.3.0 → 0.4.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 = {}
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 = {}
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,915 @@
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 })
122
210
  }
123
- cards.push(card)
124
- return card
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()
125
216
  }
126
217
 
127
- // ---- page ----
128
- function statement(parent, label, text) {
129
- if (text) add(parent, add(el('p', 'statement'), el('span', 'label', label), document.createTextNode(text)))
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 (c.risks.length) {
244
+ var rb = button('linkbtn', plural(c.risks.length, 'risk') + ' flagged', function () { selectRisks(commits.indexOf(c)) })
245
+ add(box, add(el('p', 'look'), rb))
246
+ }
247
+ 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).')))
248
+ else if (a && a.override_reason) add(box, el('p', 'override', 'Rated below what the change suggests: ' + a.override_reason))
249
+ return box
130
250
  }
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()))
251
+
252
+ // ---- reviews: whether each stage reviewed the commit as it is now ----
253
+ function reviewClass(r) {
254
+ return r.state !== 'seen' || r.verdict === 'issues' ? 'stale' : r.verdict === 'clean' ? 'review-ok' : 'review-none'
255
+ }
256
+ // A stage's verdict and how current it is, short enough for a rail row.
257
+ function reviewShort(r) {
258
+ var v = r.verdict === 'issues' ? 'issues found' : r.verdict === 'clean' ? 'no issues' : 'reviewed'
259
+ var f = r.state === 'seen' ? 'current' : r.state === 'code-changed' ? 'code changed since review' : 'gloss changed since review'
260
+ return v + ' · ' + f
261
+ }
262
+ // One compact row per stage; each opens the Reviews view in the main pane.
263
+ function reviewStatus(c) {
264
+ var ci = commits.indexOf(c)
265
+ var box = el('div', 'reviews')
266
+ if (!c.reviews.length) return add(box, el('span', 'review-none', 'No reviews recorded'))
267
+ c.reviews.forEach(function (r) {
268
+ var cls = reviewClass(r)
269
+ var b = button('rvrow', null, function () { selectReviews(ci, r.stage) })
270
+ b.setAttribute('data-fid', 'rv:' + ci + ':' + r.stage)
271
+ b.title = r.phrase
272
+ add(b, el('span', 'rvdot ' + (cls === 'review-ok' ? 'ok' : cls === 'stale' ? 'warn' : 'none')), el('span', 'rvstage', r.stage), el('span', 'rvtxt', reviewShort(r)),
273
+ el('span', 'rvn', r.entries.length))
274
+ box.appendChild(b)
275
+ })
276
+ return box
277
+ }
278
+
279
+ // ---- markdown: a small subset, built as DOM nodes (never parsed as HTML) ----
280
+ var ALNUM = /[A-Za-z0-9]/
281
+ function inline(parent, s) {
282
+ var i = 0, buf = ''
283
+ function flush() { if (buf) { parent.appendChild(text(buf)); buf = '' } }
284
+ while (i < s.length) {
285
+ var ch = s.charAt(i), j, m
286
+ if (ch === '`') {
287
+ j = s.indexOf('`', i + 1)
288
+ if (j > i + 1) { flush(); parent.appendChild(el('code', null, s.slice(i + 1, j))); i = j + 1; continue }
289
+ } else if (ch === '*' && s.charAt(i + 1) === '*') {
290
+ j = s.indexOf('**', i + 2)
291
+ if (j > i + 2) { flush(); parent.appendChild(inline(el('strong'), s.slice(i + 2, j))); i = j + 2; continue }
292
+ } else if (ch === '*' && s.charAt(i + 1) && !/[\s*]/.test(s.charAt(i + 1)) && (i === 0 || !ALNUM.test(s.charAt(i - 1)))) {
293
+ for (j = i + 2; j < s.length; j++) {
294
+ if (s.charAt(j) === '*' && !/\s/.test(s.charAt(j - 1)) && !ALNUM.test(s.charAt(j + 1)) && s.charAt(j + 1) !== '*') break
295
+ }
296
+ if (j < s.length) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
297
+ } else if (ch === '[') {
298
+ m = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(s.slice(i))
299
+ if (m) { flush(); inline(parent, m[1]); parent.appendChild(el('span', 'muted', ' (' + m[2] + ')')); i += m[0].length; continue }
300
+ }
301
+ buf += ch
302
+ i++
303
+ }
304
+ flush()
305
+ return parent
306
+ }
307
+ // Headings # to ####, paragraphs, bullet and numbered lists (nested by
308
+ // indentation), fenced code. Anything else is a paragraph of plain text.
309
+ function markdown(src) {
310
+ var root = el('div', 'md')
311
+ var lines = String(src || '').replace(/\r/g, '').split('\n')
312
+ var para = [], stack = [] // stack of { indent, list, li }
313
+ function endPara() { if (para.length) { root.appendChild(inline(el('p'), para.join(' '))); para = [] } }
314
+ for (var i = 0; i < lines.length; i++) {
315
+ var line = lines[i], m
316
+ if (/^\s*```/.test(line)) {
317
+ endPara(); stack = []
318
+ var code = []
319
+ for (i++; i < lines.length && !/^\s*```/.test(lines[i]); i++) code.push(lines[i])
320
+ root.appendChild(add(el('pre'), el('code', null, code.join('\n'))))
321
+ continue
322
+ }
323
+ if (!line.trim()) { endPara(); continue }
324
+ if ((m = /^(#{1,4})\s+(.*)$/.exec(line))) {
325
+ endPara(); stack = []
326
+ root.appendChild(inline(el('h' + (m[1].length + 2)), m[2]))
327
+ continue
328
+ }
329
+ if ((m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line))) {
330
+ endPara()
331
+ var indent = m[1].length, tag = /^\d/.test(m[2]) ? 'ol' : 'ul'
332
+ while (stack.length && stack[stack.length - 1].indent > indent) stack.pop()
333
+ var top = stack[stack.length - 1]
334
+ if (!top || top.indent < indent || top.list.tagName.toLowerCase() !== tag) {
335
+ if (top && top.indent === indent) stack.pop()
336
+ var list = el(tag)
337
+ if (stack.length && stack[stack.length - 1].li) stack[stack.length - 1].li.appendChild(list); else root.appendChild(list)
338
+ top = { indent: indent, list: list, li: null }
339
+ stack.push(top)
340
+ }
341
+ top.li = inline(el('li'), m[3])
342
+ top.list.appendChild(top.li)
343
+ continue
344
+ }
345
+ if (stack.length && /^\s/.test(line)) { // a wrapped list item
346
+ var cur = stack[stack.length - 1]
347
+ cur.li.appendChild(text(' '))
348
+ inline(cur.li, line.trim())
349
+ continue
350
+ }
351
+ stack = []
352
+ para.push(line.trim())
353
+ }
354
+ endPara()
355
+ return root
134
356
  }
135
357
 
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))
358
+ // ---- the Reviews view in the main pane ----
359
+ function verdictOf(findings) {
360
+ var m = /VERDICT:\W*(CLEAN|ISSUES)/i.exec(String(findings).slice(-400))
361
+ return m ? m[1].toUpperCase() : null
362
+ }
363
+ function entryHead(e) {
364
+ var head = el('div', 'ehead')
365
+ add(head, el('strong', null, e.agent + (e.model ? ':' + e.model : '') + (e.mode ? ' (' + e.mode + ')' : '')),
366
+ el('span', 'muted', e.at.slice(0, 16).replace('T', ' ') + ', of ' + e.target))
367
+ var v = verdictOf(e.findings)
368
+ if (v) add(head, el('span', 'chip ' + (v === 'ISSUES' ? 'bad' : 'good'), v === 'ISSUES' ? 'issues found' : 'no issues'))
369
+ return head
370
+ }
371
+ function reviewsPane(c, stage) {
372
+ var pane = el('div')
373
+ add(pane, button('btn small back', '‹ Back', goBack))
374
+ add(pane, commitMeta(c), el('h1', 'title', 'Reviews: ' + c.subject))
375
+ if (!c.reviews.length) return add(pane, el('p', 'muted', 'No reviews recorded for this commit.'))
376
+ c.reviews.forEach(function (r) {
377
+ var sec = el('section', 'rvstagepane')
378
+ sec.id = 'rv-' + r.stage
379
+ add(sec, el('h2', null, r.stage), add(el('p', 'rvsum'), el('span', reviewClass(r), r.phrase)))
380
+ var entries = r.entries.slice().sort(function (a, b) { return a.at < b.at ? 1 : a.at > b.at ? -1 : 0 })
381
+ entries.forEach(function (e, i) {
382
+ if (i === 0) add(sec, add(el('div', 'entry'), entryHead(e), markdown(e.findings)))
383
+ else {
384
+ var d = el('details', 'entry old')
385
+ add(d, add(el('summary'), entryHead(e)), markdown(e.findings))
386
+ add(sec, d)
387
+ }
388
+ })
389
+ add(pane, sec)
151
390
  })
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))
391
+ return pane
392
+ }
393
+ function goBack() {
394
+ var b = state.back
395
+ if (b && b.kind === 'group') selectGroup(b.ci, b.gid)
396
+ else if (b) selectKind(b.ci, b.kind)
397
+ else selectKind(state.ci, 'overview')
398
+ }
399
+ function selectReviews(ci, stage) {
400
+ if (state.kind !== 'reviews' && state.kind !== 'risks') state.back = { ci: state.ci, kind: state.kind, gid: state.gid }
401
+ state.ci = ci
402
+ state.kind = 'reviews'
403
+ state.gid = null
404
+ state.stage = stage || null
405
+ show(!stage)
406
+ var t = stage && document.getElementById('rv-' + stage)
407
+ if (t) t.scrollIntoView({ block: 'start' }) // deliberately also at load: #reviews-<stage> lands on that stage
408
+ }
409
+ function honestyLabel(c) {
410
+ var honesty = find(c.reviews, function (r) { return r.stage === 'honesty' })
411
+ if (!honesty) return ['stale', 'not reviewed']
412
+ var label = honesty.state !== 'seen' ? 'changed since review'
413
+ : honesty.verdict === 'issues' ? 'honesty: issues found'
414
+ : honesty.verdict === 'clean' ? 'honesty: no issues' : 'honesty-reviewed'
415
+ return [reviewClass(honesty), label]
416
+ }
417
+
418
+ // ---- filters ----
419
+ var contractRank = IMPACTS.indexOf('contract') + 1
420
+ var passes = {
421
+ all: function () { return true },
422
+ focus: function (g, c) { return isFocus(c, g) },
423
+ contract: function (g) { return impactRank(effImpact(g)) >= contractRank },
424
+ low: function (g) { return effImpact(g) === 'local' }
425
+ }
426
+ function visibleGroups(c) {
427
+ var p = passes[state.filter]
428
+ return orderedGroups(c).filter(function (g) { return p(g, c) && !state.hidden.has(g.necessity) })
429
+ }
430
+
431
+ // ---- the order commits are listed in ----
432
+ function commitOrder() {
433
+ // data.commits is oldest first; the rail lists newest first, like git log.
434
+ var idx = commits.map(function (c, i) { return i }).reverse()
435
+ if (multi && state.hot) {
436
+ idx.sort(function (a, b) { return (levelRank(levelOf(commits[b]).level) - levelRank(levelOf(commits[a]).level)) || (b - a) })
437
+ }
438
+ return idx
439
+ }
440
+ // The groups the reader steps through: those of the open commit, in rail order.
441
+ function neighbor(dir) {
442
+ var all = commits[state.ci].order
443
+ var vis = visibleGroups(commits[state.ci]).map(function (g) { return g.id })
444
+ if (!vis.length) return null
445
+ var i = state.kind === 'group' ? all.indexOf(state.gid) : -1
446
+ if (i < 0) return vis[dir > 0 ? 0 : vis.length - 1]
447
+ // The nearest visible group after / before this one in the full order, even if the filter hides this one.
448
+ for (var j = i + dir; j >= 0 && j < all.length; j += dir) if (vis.indexOf(all[j]) >= 0) return all[j]
449
+ return null
450
+ }
451
+ // j/k and Previous/Next: groups when one is open (or on a single commit's
452
+ // overview), commits on a range's commit list.
453
+ function stepTo(dir) {
454
+ if (!multi && state.kind === 'overview' && dir < 0) return
455
+ if (!multi && state.kind === 'group' && dir < 0 && !neighbor(-1)) { selectKind(0, 'overview'); return }
456
+ if (multi && state.kind !== 'group') {
457
+ var order = commitOrder()
458
+ var next = order[order.indexOf(state.ci) + dir]
459
+ if (next !== undefined) selectKind(next, 'overview')
460
+ return
167
461
  }
462
+ var id = neighbor(dir)
463
+ if (id) selectGroup(state.ci, id)
464
+ }
465
+ function resetFilters() { state.filter = 'all'; state.hidden.clear() }
466
+
467
+ // ---- the detail pane ----
468
+ var topEl = el('header', 'top')
469
+ var railEl = el('aside', 'rail')
470
+ railEl.setAttribute('aria-label', multi ? 'Commits in the range' : 'Groups in review order')
471
+ var mainEl = el('main', 'main')
472
+ var body = add(el('div', multi ? 'body range' : 'body'), railEl, mainEl)
473
+
474
+ // What the author flagged about the commit as a whole: it belongs to the
475
+ // Overview, not under any one group.
476
+ function risksSection(c, pane) {
168
477
  if (c.risks.length) {
169
478
  var rl = el('ul', 'list')
170
479
  c.risks.forEach(function (r) { rl.appendChild(el('li', null, r)) })
171
- add(main, add(el('section', 'section'), el(h, null, 'Risks'), rl))
480
+ var h = el('h2', null, 'Risks the author flagged (' + c.risks.length + ')')
481
+ h.id = 'risks'
482
+ add(pane, h, rl)
172
483
  }
173
484
  if (c.alternatives.length) {
174
485
  var al = el('ul', 'list')
175
486
  c.alternatives.forEach(function (x) {
176
487
  al.appendChild(add(el('li'), el('strong', null, x.option), el('span', 'why', ' — ' + x.rejected_because)))
177
488
  })
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)
489
+ add(pane, el('h2', null, 'Alternatives the author rejected (' + c.alternatives.length + ')'), al)
206
490
  }
491
+ }
492
+ function riskCounts(c) {
493
+ var parts = []
494
+ if (c.risks.length) parts.push(plural(c.risks.length, 'risk'))
495
+ if (c.alternatives.length) parts.push(plural(c.alternatives.length, 'alternative'))
496
+ return parts.join(' · ')
497
+ }
498
+ // The row that opens the risks and alternatives page; none when there are neither.
499
+ function risksRow(ci) {
500
+ var c = commits[ci]
501
+ if (!c.risks.length && !c.alternatives.length) return null
502
+ var b = button('rvrow', null, function () { selectRisks(ci) })
503
+ b.setAttribute('data-fid', 'risks:' + ci)
504
+ return add(b, el('span', 'rvdot none'), el('span', 'rvstage', 'Risks and alternatives'), el('span', 'rvtxt', riskCounts(c)))
505
+ }
506
+ function risksPane(c) {
507
+ var pane = el('div')
508
+ add(pane, button('btn small back', '‹ Back', goBack))
509
+ add(pane, commitMeta(c), el('h1', 'title', 'Risks and alternatives: ' + c.subject))
510
+ risksSection(c, pane)
511
+ return pane
512
+ }
513
+ function selectRisks(ci) {
514
+ if (state.kind !== 'reviews' && state.kind !== 'risks') state.back = { ci: state.ci, kind: state.kind, gid: state.gid }
515
+ state.ci = ci
516
+ state.kind = 'risks'
517
+ state.gid = null
518
+ show(true)
519
+ }
207
520
 
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
521
+ // Previous / Next / mark read: rebuilt whenever the filters change, since
522
+ // which neighbours exist depends on the filter and the position only (not on
523
+ // read marks).
524
+ function navBar() {
525
+ var next = neighbor(1)
526
+ var nav = el('div', 'nav')
527
+ var pb = button('btn', 'Previous', function () { stepTo(-1) })
528
+ pb.setAttribute('data-fid', 'nav:prev')
529
+ var nb = button('btn', 'Next', function () { stepTo(1) })
530
+ nb.setAttribute('data-fid', 'nav:next')
531
+ pb.disabled = !neighbor(-1) && !(!multi && state.kind === 'group')
532
+ nb.disabled = !next
533
+ var rb = button('btn primary', next ? 'Mark as read and go to next' : 'Mark as read', function () { markRead(true); stepTo(1) })
534
+ rb.setAttribute('data-fid', 'nav:read')
535
+ 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) · '),
536
+ el('kbd', null, 'r'), text(' mark read · '), el('kbd', null, 'o'), text(' fold hunk')))
537
+ }
538
+ function refreshNav() {
539
+ var old = mainEl.querySelector('.nav')
540
+ if (old) old.parentNode.replaceChild(navBar(), old)
541
+ }
542
+
543
+ // The "Changes" heading with the buttons that fold every hunk of the pane.
544
+ function changesHead(title) {
545
+ var expand = button('btn small', 'Expand all hunks', function () { setCode(true) })
546
+ var collapse = button('btn small', 'Collapse all hunks', function () { setCode(false) })
547
+ expand.setAttribute('data-fid', 'code:expand')
548
+ collapse.setAttribute('data-fid', 'code:collapse')
549
+ return add(el('div', 'chead'), el('h2', null, title), add(el('span'), expand, text(' '), collapse))
550
+ }
551
+
552
+ // On a range, a group or the unannotated changes open inside their commit.
553
+ function backLink(c, pane) {
554
+ if (!multi) return
555
+ var b = button('btn small back', '‹ ' + c.subject, function () { selectKind(commits.indexOf(c), 'overview') })
556
+ b.title = 'Back to the commit'
557
+ add(pane, b)
558
+ }
559
+
560
+ function groupPane(c, g) {
561
+ var pane = el('div')
562
+ var order = c.order
563
+ var hunkN = g.hunks.length
564
+ var files = []
565
+ var perFile = {}
566
+ g.hunks.forEach(function (h) {
567
+ if (!perFile[h.file]) { perFile[h.file] = 0; files.push(h.file) }
568
+ perFile[h.file]++
569
+ })
570
+ backLink(c, pane)
571
+ add(pane, commitMeta(c))
572
+ add(pane, el('div', 'crumbs', (multi ? sha7(c) + ' · ' : '') + 'Group ' + (order.indexOf(g.id) + 1) + ' of ' + order.length + ' · ' +
573
+ plural(hunkN, 'hunk') + ' in ' + plural(files.length, 'file')))
574
+ add(pane, el('h1', 'intent', g.intent))
575
+
576
+ var tags = el('div', 'tags')
577
+ add(tags, el('span', 'chip', g.necessity))
578
+ var level = effImpact(g)
579
+ var imp = add(el('span', 'imp' + (g.impact ? '' : ' derived')), meter(level), text(impactWord(g)))
580
+ if (level) imp.style.setProperty('--m', 'var(--impact-' + level + ')')
581
+ if (!g.impact && level) imp.title = 'Derived from the diff, not declared in the gloss'
582
+ add(tags, imp, el('span', 'chip mono', g.id))
583
+ if (g.serves[0] !== 'task') add(tags, add(el('span', 'chip'), text('serves '), linkList(c, g.serves)))
584
+ else add(tags, el('span', 'chip', 'serves task'))
585
+ if (isFocus(c, g)) add(tags, el('span', 'chip', 'look here first'))
586
+ if (g.stale) add(tags, el('span', 'stale', g.stale + ' of ' + plural(g.total, 'hunk') + ' changed or gone'))
587
+ add(tags, stats(g.added, g.removed))
588
+ add(pane, tags)
589
+ if (g.impact_reason) add(pane, el('p', 'reason', g.impact_reason))
590
+ warnings(c, pane)
591
+
592
+ var cards = el('div', 'cards')
593
+ function card(label, value) {
594
+ if (value) add(cards, add(el('div', 'card'), el('div', 'eyebrow', label), el('p', null, value)))
595
+ }
596
+ card('If removed', g.removal_impact)
597
+ card('Evidence', g.evidence)
598
+ card('Notes', g.notes)
599
+ add(pane, cards)
600
+
601
+ add(pane, changesHead('Changes'))
602
+ if (files.length) {
603
+ var fl = el('div', 'files')
604
+ files.forEach(function (f) { add(fl, add(el('div', 'frow'), el('span', 'mono', f), el('span', 'muted', plural(perFile[f], 'hunk')))) })
605
+ add(pane, fl)
606
+ }
607
+ g.hunks.forEach(function (h) { pane.appendChild(hunkView(c, h, g.id)) })
608
+ add(pane, navBar())
609
+ return pane
610
+ }
611
+
612
+ function unannotatedBox(c) {
613
+ var box = add(el('div', 'unannotated'), el('p', null, c.glossed
614
+ ? 'These changes are in the commit but no group explains them.'
615
+ : 'This commit has no gloss, so nothing explains these changes.'))
616
+ c.unannotated.forEach(function (h) { box.appendChild(hunkView(c, h, 'un')) })
617
+ return box
618
+ }
619
+ function unannotatedPane(c) {
620
+ var pane = el('div')
621
+ backLink(c, pane)
622
+ add(pane, commitMeta(c), el('h1', 'title', (c.glossed ? 'Unannotated changes' : 'Changes') + ' (' + c.unannotated.length + ')'))
623
+ warnings(c, pane)
624
+ add(pane, changesHead('Changes'), unannotatedBox(c))
625
+ return pane
626
+ }
627
+
628
+ // Share of changed lines per necessity, and the groups of the commit.
629
+ function necessityBar(c) {
630
+ var total = c.groups.reduce(function (s, g) { return s + g.weight }, 0) || 1
251
631
  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)
632
+ var legend = el('div', 'legend')
633
+ data.necessity.forEach(function (n) {
634
+ var gs = c.groups.filter(function (g) { return g.necessity === n.name })
635
+ if (!gs.length) return
636
+ var weight = gs.reduce(function (s, g) { return s + g.weight }, 0)
637
+ var seg = el('span')
638
+ seg.style.setProperty('--c', 'var(--n-' + n.name + ')')
257
639
  seg.style.width = (100 * weight / total) + '%'
258
640
  seg.title = n.name + ': ' + Math.round(100 * weight / total) + '% of changed lines'
259
641
  bar.appendChild(seg)
642
+ var item = el('span', null, n.name + ' ' + gs.length)
643
+ item.title = n.help
644
+ var dot = el('span', 'dot')
645
+ dot.style.setProperty('--c', 'var(--n-' + n.name + ')')
646
+ item.insertBefore(dot, item.firstChild)
647
+ legend.appendChild(item)
648
+ })
649
+ return [bar, legend]
650
+ }
260
651
 
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)
652
+ // The groups the author says to read first, as links; nothing when none are declared.
653
+ function focusSection(c, ci, pane) {
654
+ var focus = c.attention && c.attention.focus ? c.attention.focus : []
655
+ var groups = focus.map(function (id) { return groupOf(c, id) }).filter(Boolean)
656
+ if (!groups.length) return
657
+ var list = el('div', 'focuscards')
658
+ groups.forEach(function (g) {
659
+ var b = button('focuscard', null, function () { selectGroup(ci, g.id) })
660
+ var level = effImpact(g)
661
+ var imp = add(el('span', 'imp'), meter(level), text(impactWord(g)))
662
+ if (level) imp.style.setProperty('--m', 'var(--impact-' + level + ')')
663
+ add(b, el('span', 'fctitle', g.intent), add(el('span', 'fcmeta'), imp, el('span', 'chip', g.necessity)))
664
+ if (g.impact_reason) add(b, el('span', 'fcreason', g.impact_reason))
665
+ list.appendChild(b)
273
666
  })
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)
667
+ add(pane, el('h2', null, 'Look here first'), list)
668
+ }
669
+
670
+ function overviewPane(c) {
671
+ var ci = commits.indexOf(c)
672
+ var pane = el('div')
673
+ if (multi) add(pane, el('div', 'eyebrow', 'Commit ' + (ci + 1) + ' of ' + commits.length + ' · ' + new Date(c.date).toLocaleDateString()))
674
+ add(pane, el('h1', 'title', c.subject), commitMeta(c))
675
+ if (!c.glossed) {
676
+ var w = add(el('section', 'warnbox'), el('div', 't', 'No gloss on this commit'),
677
+ el('p', null, 'Its whole diff is unannotated, so there is no attention level, no groups and no reasons to read.'),
678
+ el('p', 'cmd', 'git gloss add ' + sha7(c)))
679
+ add(pane, w)
680
+ add(pane, changesHead('Changes (' + c.unannotated.length + ')'), unannotatedBox(c))
681
+ return pane
682
+ }
683
+ warnings(c, pane)
684
+ add(pane, attentionCard(c))
685
+ statement(pane, 'Task', c.task)
686
+ statement(pane, 'Summary', c.summary)
687
+ focusSection(c, ci, pane)
688
+ if (c.groups.length) {
689
+ var nb = necessityBar(c)
690
+ add(pane, el('h2', null, plural(c.groups.length, 'group')), nb[0], nb[1])
691
+ if (multi) groupList(ci, pane, true)
692
+ else {
693
+ var chips = el('div', 'chips')
694
+ orderedGroups(c).forEach(function (g) {
695
+ var b = button('chip link', null, function () { selectGroup(ci, g.id) })
696
+ add(b, el('b', null, g.id), text(' '), el('span', null, g.necessity))
697
+ chips.appendChild(b)
698
+ })
699
+ add(pane, chips)
299
700
  }
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
- })
701
+ }
702
+ if (multi) {
703
+ var rr = risksRow(ci)
704
+ if (rr) add(pane, el('h2', null, 'Risks and alternatives'), rr)
705
+ add(pane, el('h2', null, 'Reviews'), reviewStatus(c))
706
+ }
707
+ return pane
708
+ }
709
+
710
+ function renderPane(scroll) {
711
+ var c = commits[state.ci]
712
+ var g = state.kind === 'group' ? groupOf(c, state.gid) : null
713
+ 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)
714
+ keepFocus(function () {
715
+ mainEl.textContent = ''
716
+ add(mainEl, pane)
717
+ add(mainEl, footer())
718
+ syncFold()
719
+ }, true)
720
+ if (scroll && !booting) {
721
+ if (window.matchMedia && window.matchMedia('(min-width: 900px)').matches) window.scrollTo(0, 0)
722
+ else mainEl.scrollIntoView({ block: 'start' })
723
+ }
724
+ }
306
725
 
726
+ function footer() {
307
727
  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.'))
728
+ add(foot, text('Keys: '), el('kbd', null, 'j'), text(' / '), el('kbd', null, 'k'),
729
+ 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) · '),
730
+ el('kbd', null, 'e'), text(' expand all hunks · '), el('kbd', null, 'c'), text(' collapse all hunks · '),
731
+ el('kbd', null, 'r'), text(' mark group read or unread.'))
312
732
  add(foot, el('p', null, 'A snapshot generated by git-gloss ' + new Date(data.generated).toLocaleString() +
313
733
  '. 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++
734
+ (data.range || commits[0].sha.slice(0, 10)) + '.'))
735
+ return foot
736
+ }
737
+
738
+ // ---- the top bar ----
739
+ function filterSeg(c) {
740
+ var seg = el('div', 'seg')
741
+ seg.setAttribute('role', 'group')
742
+ seg.setAttribute('aria-label', 'Show groups')
743
+ FILTERS.forEach(function (f) {
744
+ var n = c.groups.filter(function (g) { return passes[f[0]](g, c) }).length
745
+ var b = add(button(null, f[1]), el('span', 'n', n))
746
+ b.setAttribute('aria-pressed', String(state.filter === f[0]))
747
+ b.setAttribute('data-fid', 'seg:' + f[0])
748
+ b.addEventListener('click', function () { state.filter = f[0]; renderChrome() })
749
+ seg.appendChild(b)
750
+ })
751
+ return seg
752
+ }
753
+ function renderTop() {
754
+ var c = commits[state.ci]
755
+ topEl.textContent = ''
756
+ add(topEl, el('span', 'brand', 'git gloss'))
757
+ if (multi) {
758
+ var glossed = commits.filter(function (x) { return x.glossed }).length
759
+ var vetted = commits.filter(function (x) {
760
+ return x.glossed && x.reviews.some(function (r) { return r.stage === 'honesty' && r.state === 'seen' })
761
+ }).length
762
+ add(topEl, el('span', 'sha', data.range || 'commits'),
763
+ el('span', 'subject', plural(commits.length, 'commit') + ', ' + glossed + ' glossed' + (glossed ? ', ' + vetted + ' honesty-reviewed' : '')))
764
+ var order = el('div', 'seg')
765
+ order.setAttribute('role', 'group')
766
+ order.setAttribute('aria-label', 'Order commits')
767
+ ;[[true, 'Hottest first'], [false, 'History order']].forEach(function (o) {
768
+ var b = button(null, o[1], function () { state.hot = o[0]; renderChrome() })
769
+ b.setAttribute('aria-pressed', String(state.hot === o[0]))
770
+ b.setAttribute('data-fid', 'order:' + o[1])
771
+ order.appendChild(b)
326
772
  })
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)
773
+ add(topEl, order)
774
+ } else {
775
+ var subject = el('span', 'subject', c.subject)
776
+ subject.title = c.subject
777
+ add(topEl, el('span', 'sha', c.sha.slice(0, 7)), subject)
778
+ }
779
+ var counts = readCounts()
780
+ var fill = el('i')
781
+ fill.style.width = (counts.total ? Math.round(100 * counts.read / counts.total) : 0) + '%'
782
+ var prog = add(el('div', 'prog'), el('span', null, counts.read + ' of ' + counts.total + ' read'), add(el('span', 'track'), fill))
783
+ prog.setAttribute('aria-live', 'polite')
784
+ if (!multi) add(topEl, filterSeg(c))
785
+ add(topEl, prog)
786
+ }
787
+
788
+ // ---- the rail ----
789
+ function groupRow(ci, g) {
790
+ var c = commits[ci]
791
+ var k = key(c, g.id)
792
+ var sel = state.ci === ci && state.kind === 'group' && state.gid === g.id
793
+ var done = !!readSet(c)[g.id]
794
+ var row = el('div', 'row' + (sel ? ' sel' : '') + (done ? ' done' : ''))
795
+ var chk = button('check', null, function () { setRead(c, g.id, !readSet(c)[g.id]); renderChrome() })
796
+ chk.setAttribute('aria-pressed', String(done))
797
+ chk.setAttribute('aria-label', (done ? 'Mark unread: ' : 'Mark read: ') + g.id)
798
+ chk.setAttribute('data-fid', 'chk:' + k)
799
+ add(chk, el('span', null, done ? '✓' : ''))
800
+ var pick = button('pick', null, function () { selectGroup(ci, g.id) })
801
+ if (sel) pick.setAttribute('aria-current', 'true')
802
+ pick.setAttribute('data-fid', 'pick:' + k)
803
+ var sub = add(el('span', 'rsub'), el('span', 'mono', g.id), text(' · ' + g.necessity + ' · ' + impactWord(g)))
804
+ add(pick, meter(effImpact(g)), add(el('span', 'rtxt'), el('span', 'rtitle', g.intent), sub))
805
+ if (isFocus(c, g)) {
806
+ var flag = el('span', 'flag', 'first')
807
+ var lv = levelOf(c).level
808
+ if (lv) flag.style.setProperty('--lv', 'var(--attention-' + lv + ')')
809
+ add(pick, flag)
810
+ }
811
+ return add(row, chk, pick)
812
+ }
813
+ function extraRow(ci, kind, label, sub) {
814
+ var sel = state.ci === ci && state.kind === kind
815
+ var row = el('div', sel ? 'row sel' : 'row')
816
+ var pick = button('pick', null, function () { selectKind(ci, kind) })
817
+ if (sel) pick.setAttribute('aria-current', 'true')
818
+ pick.setAttribute('data-fid', 'extra:' + kind + ':' + ci)
819
+ add(pick, add(el('span', 'rtxt'), el('span', 'rtitle', label), el('span', 'rsub', sub)))
820
+ return add(row, pick)
821
+ }
822
+
823
+ // The group list of one commit: necessity filter chips, then the rows.
824
+ function groupList(ci, parent, withSeg) {
825
+ var c = commits[ci]
826
+ if (withSeg) add(parent, add(el('div', 'filters'), filterSeg(c)))
827
+ var present = data.necessity.filter(function (n) { return c.groups.some(function (g) { return g.necessity === n.name }) })
828
+ if (present.length > 1 || present.some(function (n) { return state.hidden.has(n.name) })) {
829
+ var necs = el('div', 'necs')
830
+ necs.setAttribute('role', 'group')
831
+ necs.setAttribute('aria-label', 'Show necessity')
832
+ present.forEach(function (n) {
833
+ var count = c.groups.filter(function (g) { return g.necessity === n.name }).length
834
+ var chip = add(button('chip', null, function () {
835
+ if (state.hidden.has(n.name)) state.hidden.delete(n.name); else state.hidden.add(n.name)
836
+ renderChrome()
837
+ }), el('span', null, n.name), el('span', 'count', count))
838
+ chip.title = n.help + ' (click to show or hide)'
839
+ chip.setAttribute('aria-pressed', String(!state.hidden.has(n.name)))
840
+ chip.setAttribute('data-fid', 'nec:' + n.name)
841
+ necs.appendChild(chip)
842
+ })
843
+ add(parent, necs)
844
+ }
845
+ var rows = el('div', 'rows')
846
+ var vis = visibleGroups(c)
847
+ vis.forEach(function (g) { rows.appendChild(groupRow(ci, g)) })
848
+ if (!vis.length && c.groups.length) {
849
+ var none = el('div', 'emptyrows', 'No groups match this filter. ')
850
+ add(none, button('btn small', 'Show all', function () { resetFilters(); renderChrome() }))
851
+ add(parent, none)
852
+ }
853
+ if (c.unannotated.length && c.glossed) rows.appendChild(extraRow(ci, 'unannotated', 'Unannotated changes (' + c.unannotated.length + ')', 'in the commit, explained by no group'))
854
+ add(parent, rows)
855
+ }
856
+
857
+ function commitRow(ci) {
858
+ var c = commits[ci]
859
+ var lv = levelOf(c)
860
+ var sel = state.ci === ci
861
+ var wrap = el('div', sel ? 'crow sel' : 'crow')
862
+ var pick = button('pick', null, function () { selectKind(ci, 'overview') })
863
+ if (sel) pick.setAttribute('aria-current', 'true')
864
+ pick.setAttribute('data-fid', 'commit:' + c.sha)
865
+ var level = el('span', 'lv')
866
+ if (lv.level) {
867
+ level.style.setProperty('--lv', 'var(--attention-' + lv.level + ')')
868
+ add(level, gauge(lv.level, true), el('span', null, lv.level.toUpperCase()))
869
+ } else add(level, el('span', null, c.glossed ? 'UNRATED' : 'NO GLOSS'))
870
+ var meta
871
+ if (c.glossed) {
872
+ var rs = readSet(c)
873
+ var done = c.groups.filter(function (g) { return rs[g.id] }).length
874
+ meta = plural(c.groups.length, 'group') + ' · ' + plural(fileCount(c), 'file') + (lv.source === 'unrated' ? '' : ' · ' + lv.source) + ' · ' + done + '/' + c.groups.length + ' read'
875
+ } else {
876
+ var added = 0, removed = 0
877
+ c.unannotated.forEach(function (h) { added += h.added; removed += h.removed })
878
+ meta = plural(fileCount(c), 'file') + ' · +' + added + ' −' + removed
879
+ }
880
+ var metaEl = el('span', 'cmeta', meta)
881
+ if (c.glossed) {
882
+ if (c.warnings.length) add(metaEl, text(' · '), el('span', 'stale', 'stale gloss'))
883
+ var hl = honestyLabel(c)
884
+ add(metaEl, text(' · '), el('span', hl[0], hl[1]))
885
+ } else add(metaEl, text(' · '), el('span', 'stale', 'no gloss'))
886
+ add(pick, add(el('span', 'rtop'), el('span', 'sha', sha7(c)), el('span', 'sha', new Date(c.date).toLocaleDateString()), level),
887
+ el('span', 'csubject', c.subject), metaEl)
888
+ add(wrap, pick)
889
+ return wrap
890
+ }
891
+
892
+ function railSummary() {
893
+ var counts = {}
894
+ var strip = el('div', 'strip')
895
+ strip.setAttribute('aria-hidden', 'true')
896
+ var order = commitOrder()
897
+ order.forEach(function (ci) {
898
+ var lv = levelOf(commits[ci])
899
+ var name = lv.level || (commits[ci].glossed ? 'unrated' : 'no gloss')
900
+ counts[name] = (counts[name] || 0) + 1
901
+ var seg = el('i', lv.level ? null : 'none')
902
+ if (lv.level) seg.style.setProperty('--lv', 'var(--attention-' + lv.level + ')')
903
+ strip.appendChild(seg)
904
+ })
905
+ var line = el('div', 'line')
906
+ var first = true
907
+ LEVELS.slice().reverse().concat(['unrated', 'no gloss']).forEach(function (name) {
908
+ if (!counts[name]) return
909
+ if (!first) line.appendChild(text(' · '))
910
+ first = false
911
+ add(line, el('b', null, counts[name]), text(' ' + (name === 'no gloss' ? 'without a gloss' : name)))
912
+ })
913
+ var box = add(el('div', 'sum'), el('div', 'eyebrow', 'Range at a glance'), strip, line)
914
+ var top = commits[order[0]]
915
+ if (levelOf(top).level) {
916
+ var link = el('a', null, sha7(top) + ' ' + top.subject)
917
+ link.href = '#c-' + sha7(top)
918
+ link.addEventListener('click', function (ev) { ev.preventDefault(); selectKind(order[0], 'overview') })
919
+ add(box, add(el('div', 'line'), text(state.hot ? 'Read first: ' : 'Hottest: '), link))
920
+ }
921
+ return box
922
+ }
923
+
924
+ function renderRail() {
925
+ railEl.textContent = ''
926
+ if (multi) {
927
+ add(railEl, railSummary(), add(el('div', 'railhead'), el('div', 'eyebrow', state.hot ? 'Hottest first, then newest' : 'Newest first')))
928
+ var list = el('div', 'commits')
929
+ commitOrder().forEach(function (ci) { list.appendChild(commitRow(ci)) })
930
+ add(railEl, list)
931
+ if (data.merges.length) {
932
+ var ml = add(el('div', 'mergelist'), el('div', 'eyebrow', 'Merges, not reviewed'))
933
+ data.merges.forEach(function (m) { ml.appendChild(add(el('div'), el('span', 'mono', m.sha.slice(0, 7)), text(' ' + m.subject))) })
934
+ add(railEl, ml)
935
+ }
936
+ return
937
+ }
938
+ var c = commits[0]
939
+ var ov = el('div', 'overviewrow')
940
+ var ovRow = extraRow(0, 'overview', 'Overview', 'task, summary, where to start')
941
+ var lvl = levelOf(c).level
942
+ if (lvl) {
943
+ var chip = add(el('span', 'lv'), gauge(lvl, true), el('span', null, lvl.toUpperCase()))
944
+ chip.style.setProperty('--lv', 'var(--attention-' + lvl + ')')
945
+ chip.title = 'Attention: ' + lvl + (c.attention ? ' (declared)' : ' (derived)')
946
+ ovRow.querySelector('.pick').appendChild(chip)
947
+ }
948
+ add(ov, ovRow)
949
+ add(railEl, ov)
950
+ add(railEl, add(el('div', 'railhead'), el('div', 'eyebrow', 'Review order · look first, then by impact')))
951
+ groupList(0, railEl)
952
+ var rr = risksRow(0)
953
+ if (rr) add(railEl, add(el('div', 'railsec'), el('div', 'eyebrow', 'Risks and alternatives'), rr))
954
+ add(railEl, add(el('div', 'railfoot'), el('div', 'eyebrow', 'Reviews'), reviewStatus(c)))
955
+ }
956
+
957
+ // Run a re-render and give keyboard focus back to the control that had it:
958
+ // the one with the same data-fid, or if that is gone or disabled (Next on the
959
+ // last group) the enabled button that is left in the same bar.
960
+ function keepFocus(render, pane) {
961
+ var active = document.activeElement
962
+ var fid = active && active.getAttribute ? active.getAttribute('data-fid') : null
963
+ var inPane = !!(pane && active && active !== document.body && mainEl.contains(active))
964
+ render()
965
+ var target = null
966
+ if (fid) {
967
+ target = app.querySelector('[data-fid="' + CSS.escape(fid) + '"]')
968
+ if (!target || target.disabled) {
969
+ var bar = fid.indexOf('nav:') === 0 ? '.nav' : fid.indexOf('code:') === 0 ? '.chead' : null
970
+ target = bar ? mainEl.querySelector(bar + ' button:not(:disabled)') : null
971
+ }
972
+ }
973
+ // A link or card in the pane that opened another pane: focus moves to the new pane's heading.
974
+ if (!target && inPane) {
975
+ target = mainEl.querySelector('h1')
976
+ if (target) target.setAttribute('tabindex', '-1')
977
+ }
978
+ if (target) target.focus({ preventScroll: true })
979
+ }
980
+
981
+ // Re-render the rail and top bar (and what depends on the filters in the
982
+ // pane), keeping keyboard focus on the same control.
983
+ function renderChrome() {
984
+ keepFocus(function () {
985
+ renderTop()
986
+ renderRail()
987
+ if (multi && state.kind === 'overview') renderPane(false)
988
+ else refreshNav()
989
+ })
990
+ }
991
+
992
+ // ---- selection, kept in the hash so links and reloads land on it ----
993
+ function hashFor() {
994
+ var c = commits[state.ci]
995
+ if (state.kind === 'group') return '#g-' + key(c, state.gid)
996
+ if (state.kind === 'reviews') return '#reviews' + (multi ? '-' + sha7(c) : '') + (state.stage ? '-' + encodeURIComponent(state.stage) : '')
997
+ if (state.kind === 'risks') return '#risks' + (multi ? '-' + sha7(c) : '')
998
+ if (state.kind === 'unannotated') return '#unannotated' + (multi ? '-' + sha7(c) : '')
999
+ return multi ? '#c-' + sha7(c) : '#overview'
1000
+ }
1001
+ function remember() {
1002
+ try { history.replaceState(null, '', hashFor()) } catch (e) { /* not allowed in this context */ }
1003
+ }
1004
+ function show(scroll) {
1005
+ remember()
1006
+ renderChrome()
1007
+ renderPane(scroll)
1008
+ }
1009
+ function selectKind(ci, kind) {
1010
+ state.ci = ci
1011
+ state.kind = kind
1012
+ state.gid = null
1013
+ show(true)
1014
+ }
1015
+ function selectGroup(ci, gid) {
1016
+ var c = commits[ci]
1017
+ var g = groupOf(c, gid)
1018
+ if (!g) return
1019
+ // Following a link to a group the filters hide turns them off.
1020
+ if (visibleGroups(c).indexOf(g) < 0) resetFilters()
1021
+ state.ci = ci
1022
+ state.kind = 'group'
1023
+ state.gid = gid
1024
+ show(true)
1025
+ }
1026
+ function applyHash() {
1027
+ var h
1028
+ try { h = decodeURIComponent(location.hash.slice(1)) } catch (e) { return false }
1029
+ var i, j, c
1030
+ if (h.indexOf('g-') === 0) {
1031
+ for (i = 0; i < commits.length; i++) {
1032
+ for (j = 0; j < commits[i].groups.length; j++) {
1033
+ if (key(commits[i], commits[i].groups[j].id) === h.slice(2)) { selectGroup(i, commits[i].groups[j].id); return true }
1034
+ }
1035
+ }
1036
+ } else if (h === 'overview') { selectKind(0, 'overview'); return true }
1037
+ else if (h === 'risks' && !multi) { selectRisks(0); return true }
1038
+ else if (h.indexOf('risks-') === 0 && multi) {
1039
+ for (i = 0; i < commits.length; i++) if (sha7(commits[i]) === h.slice(6)) { selectRisks(i); return true }
1040
+ }
1041
+ else if (h.indexOf('c-') === 0 || h.indexOf('unannotated-') === 0 || h === 'unannotated') {
1042
+ var sha = h.slice(h.indexOf('-') + 1)
1043
+ for (i = 0; i < commits.length; i++) {
1044
+ c = commits[i]
1045
+ if ((h === 'unannotated' && i === 0) || (h !== 'unannotated' && sha7(c) === sha)) {
1046
+ selectKind(i, h.indexOf('c-') === 0 ? 'overview' : 'unannotated')
1047
+ return true
1048
+ }
1049
+ }
1050
+ } else if (h === 'reviews' || h.indexOf('reviews-') === 0) {
1051
+ var rest = h.slice(8)
1052
+ if (!multi) { selectReviews(0, rest || null); return true }
1053
+ for (i = 0; i < commits.length; i++) {
1054
+ if (sha7(commits[i]) === rest.slice(0, 7)) { selectReviews(i, rest.slice(8) || null); return true }
1055
+ }
1056
+ }
1057
+ return false
1058
+ }
1059
+
1060
+ // ---- actions ----
1061
+ function currentGroup() {
1062
+ return state.kind === 'group' ? groupOf(commits[state.ci], state.gid) : null
1063
+ }
1064
+ function markRead(on) {
1065
+ var g = currentGroup()
1066
+ if (!g) return
1067
+ setRead(commits[state.ci], g.id, on)
1068
+ renderChrome()
366
1069
  }
367
1070
 
368
1071
  document.addEventListener('keydown', function (ev) {
369
1072
  if (ev.metaKey || ev.ctrlKey || ev.altKey) return
370
1073
  var t = ev.target
371
1074
  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)
1075
+ if (ev.key === 'j') stepTo(1)
1076
+ else if (ev.key === 'k') stepTo(-1)
1077
+ else if (ev.key === 'o') {
1078
+ var f = document.activeElement
1079
+ var w = f && f.closest ? f.closest('.hunk') : null
1080
+ var tg = (w && w.querySelector('.htoggle')) || mainEl.querySelector('.htoggle')
1081
+ if (tg) { setHunkOpen(tg.closest('.hunk'), tg.getAttribute('aria-expanded') !== 'true'); syncFold() }
1082
+ }
1083
+ else if (ev.key === 'e') setCode(true)
1084
+ else if (ev.key === 'c') setCode(false)
1085
+ else if (ev.key === 'r') {
1086
+ var g = currentGroup()
1087
+ if (g) markRead(!readSet(commits[state.ci])[g.id])
1088
+ }
377
1089
  })
1090
+ window.addEventListener('hashchange', function () { applyHash() })
378
1091
 
379
- render()
380
- if (location.hash.indexOf('#g-') === 0) {
381
- try { focusGroup(decodeURIComponent(location.hash.slice(3)), true, true) } catch (e) { /* malformed hash */ }
1092
+ // ---- start ----
1093
+ var desk = add(el('div', 'desk'), topEl, body)
1094
+ app.textContent = ''
1095
+ app.appendChild(desk)
1096
+ if (multi) {
1097
+ var first = commitOrder()[0]
1098
+ state.ci = first
1099
+ state.kind = 'overview'
382
1100
  }
1101
+ if (!applyHash()) { renderChrome(); renderPane(false); remember() }
1102
+ booting = false
383
1103
  })()