@leftoff/cli 0.9.17-beta.4 → 0.9.17-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/navrail.js ADDED
@@ -0,0 +1,99 @@
1
+ // 0.9.18 The left nav on every screen (feedback, Oct 28: "it seems weird that I
2
+ // need to click on Board first to see the left hand nav").
3
+ //
4
+ // From 700px the rail stays beside every page, full screen and the walkthrough
5
+ // included. Two widths: expanded (84px, icon over label, the rail as it always
6
+ // was) and collapsed (56px, icons with a tip). The toggle at the rail's foot,
7
+ // ⌘B, or [ when you're not typing, switches them, remembered on this device. When the
8
+ // room is short (under 1100px, or a full-screen chat that would get narrower
9
+ // than NAV_CHAT_MIN beside the session panel) the rail collapses for that view
10
+ // only: your saved choice is untouched and comes back when there's room.
11
+ //
12
+ // Under 700px there's no rail: a menu button (and, in a Home Screen app or a
13
+ // non-Safari browser, a swipe in from the left edge) opens the nav as a drawer
14
+ // over the page. Safari's own back-swipe owns that edge in the browser, so there
15
+ // the button is the way in.
16
+ //
17
+ // Pure functions and a tiny store (the unit tests use a fake storage); app.js wires them.
18
+
19
+ export const NAV_KEY = 'lo-nav-v1' // '1' collapsed, '0' expanded (an explicit choice); none: expanded
20
+ export const RAIL_MIN = 700 // from here the rail; under it the phone's bar and the drawer
21
+ export const RAIL_ROOMY = 1100 // under this the rail is collapsed whatever you chose
22
+ export const RAIL_W_OPEN = 84, RAIL_W_SLIM = 56
23
+ export const NAV_CHAT_MIN = 560 // a full-screen chat keeps at least this beside an expanded rail
24
+ export const EDGE_PX = 20 // a drawer swipe starts this close to the left edge
25
+ export const SWIPE_MIN = 48 // and travels at least this far, mostly sideways
26
+
27
+ export function createNavStore(storage) {
28
+ const stored = () => { try { const v = storage ? storage.getItem(NAV_KEY) : null; return v === '1' || v === '0' ? v : null } catch { return null } }
29
+ return {
30
+ collapsed: () => stored() === '1',
31
+ explicit: () => stored() != null,
32
+ set(collapsed) { try { storage && storage.setItem(NAV_KEY, collapsed ? '1' : '0') } catch {} },
33
+ toggle() { const c = stored() !== '1'; this.set(c); return c },
34
+ }
35
+ }
36
+
37
+ // What the rail shows now. width: the window's CSS width; saved: your choice
38
+ // (true = collapsed); side: the full-screen session panel's width when it's open
39
+ // beside the chat (0 when there's none, or it's a rail / collapsed).
40
+ // → { rail, collapsed, auto } — auto: collapsed by the room, not by you.
41
+ export function navState({ width = 0, saved = false, side = 0 } = {}) {
42
+ const w = Number(width) || 0
43
+ if (w < RAIL_MIN) return { rail: false, collapsed: false, auto: false }
44
+ const tight = w < RAIL_ROOMY || (side > 0 && w - RAIL_W_OPEN - side < NAV_CHAT_MIN)
45
+ if (saved) return { rail: true, collapsed: true, auto: false }
46
+ return { rail: true, collapsed: tight, auto: tight }
47
+ }
48
+
49
+ // The toggle's words for the state shown.
50
+ export function toggleWords({ collapsed = false, auto = false } = {}, key = '⌘B') {
51
+ if (auto) return { label: 'Expand the sidebar', tip: 'Not enough room to expand here', icon: 'panel-left-open' }
52
+ return collapsed
53
+ ? { label: 'Expand the sidebar', tip: 'Expand sidebar (' + key + ')', icon: 'panel-left-open' }
54
+ : { label: 'Collapse the sidebar', tip: 'Collapse sidebar (' + key + ')', icon: 'panel-left-close' }
55
+ }
56
+
57
+ // ⌘B (Ctrl+B off a Mac) toggles the rail from anywhere, the composer included,
58
+ // but not inside the terminal (Ctrl+B is tmux's); [ does too when you're not typing.
59
+ // (⌘\ is the session panel's, ⌘⇧[ ] the tabs'.)
60
+ export function isNavKey(ev, { typing = false, term = false, mac = true } = {}) {
61
+ if (!ev || ev.altKey) return false
62
+ const mod = mac ? ev.metaKey && !ev.ctrlKey : ev.ctrlKey && !ev.metaKey
63
+ if (mod && !ev.shiftKey && (ev.key === 'b' || ev.key === 'B')) return !term
64
+ return ev.key === '[' && !ev.metaKey && !ev.ctrlKey && !typing
65
+ }
66
+ export const navKeyLabel = (mac = true) => (mac ? '⌘B' : 'Ctrl+B')
67
+
68
+ // Mobile Safari in the browser (not a Home Screen app): its back-swipe owns the left edge.
69
+ export function safariOwnsEdge(ua = '', standalone = false) {
70
+ const s = String(ua)
71
+ const ios = /iP(hone|ad|od)/.test(s) || (/Macintosh/.test(s) && /Mobile/.test(s))
72
+ return ios && !standalone && /Safari/.test(s) && !/CriOS|FxiOS|EdgiOS/.test(s)
73
+ }
74
+
75
+ // A touch that starts this close to the left edge may open the drawer.
76
+ export const edgeStart = (x, edge = EDGE_PX) => Number(x) >= 0 && Number(x) <= edge
77
+ // What a finished swipe means: 'open' (rightwards from the edge), 'close'
78
+ // (leftwards on the open drawer) or null (too short, or mostly up and down).
79
+ export function swipeIntent(dx, dy, open) {
80
+ const x = Number(dx) || 0, y = Number(dy) || 0
81
+ if (Math.abs(x) < SWIPE_MIN || Math.abs(x) < Math.abs(y) * 1.5) return null
82
+ if (!open && x > 0) return 'open'
83
+ if (open && x < 0) return 'close'
84
+ return null
85
+ }
86
+
87
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
88
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
89
+
90
+ // The drawer: the nav's destinations as one list. items: [{ href, route, label, svg, on, count }]
91
+ // (svg is the nav's own inline icon markup).
92
+ export function drawerHtml(items = [], { title = 'Leftoff' } = {}) {
93
+ const row = (it) => '<a class="nd-item' + (it.on ? ' on' : '') + '" href="' + esc(it.href) + '"' + (it.route ? ' data-route="' + esc(it.route) + '"' : '') + (it.on ? ' aria-current="page"' : '') + '>' +
94
+ (it.svg || '') + '<span class="nd-lbl">' + esc(it.label) + '</span>' + (it.on ? '<span class="nd-cur">Current</span>' : '') +
95
+ (it.count ? '<span class="nd-n" aria-label="' + esc(it.count + ' waiting') + '">' + esc(String(it.count)) + '</span>' : '') + '</a>'
96
+ return '<div class="nd-head"><img src="favicon.svg" class="logo" alt="" width="22" height="22"><span class="nd-title">' + esc(title) + '</span>' +
97
+ '<button type="button" class="nd-x" data-nd-close aria-label="Close the menu" data-tip="Close (Esc)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></div>' +
98
+ '<nav class="nd-list" aria-label="Sections">' + items.map(row).join('') + '</nav>'
99
+ }
package/ui/semdiff.js CHANGED
@@ -19,6 +19,31 @@ const GROUP_ICON = { repeated: 'repeat', tests: 'list-checks', mechanical: 'laye
19
19
  const n0 = (n) => Number(n || 0).toLocaleString('en-US')
20
20
  const plural = (n, w, ws) => n0(n) + ' ' + (n === 1 ? w : ws || w + 's')
21
21
 
22
+ // 0.9.18 (Joel): a file's status the way GitHub draws it, by shape: a square with
23
+ // + (added), a dot (modified), − (deleted) or an arrow (renamed), with a word in
24
+ // its tip and label. Colour helps but is never the only signal.
25
+ export const FILE_STATUS = {
26
+ added: { word: 'Added', icon: 'square-plus' },
27
+ modified: { word: 'Modified', icon: 'square-dot' },
28
+ deleted: { word: 'Deleted', icon: 'square-minus' },
29
+ renamed: { word: 'Renamed', icon: 'square-arrow-right' },
30
+ }
31
+ export function statusWord(f) {
32
+ const st = FILE_STATUS[f && f.status] ? f.status : 'modified'
33
+ return st === 'renamed' && f.oldPath ? 'Renamed from ' + f.oldPath : FILE_STATUS[st].word
34
+ }
35
+ export function statusHtml(f, { esc, icon }) {
36
+ const st = FILE_STATUS[f && f.status] ? f.status : 'modified'
37
+ const w = statusWord(f)
38
+ return '<span class="wt-fst wt-fst-' + st + '" data-st="' + st + '" data-tip="' + esc(w) + '" role="img" aria-label="' + esc(w) + '">' + icon(FILE_STATUS[st].icon) + '</span>'
39
+ }
40
+ // path -> { status, oldPath } from the answer's own Files list (git's new / deleted file mode, rename)
41
+ export function statusMap(data) {
42
+ const m = new Map()
43
+ for (const f of (data && Array.isArray(data.files) ? data.files : [])) m.set(f.path, f)
44
+ return m
45
+ }
46
+
22
47
  // The quiet count on a tab and a board card: an eye and a number, only when there's something.
23
48
  export function eyeCountHtml(n, { esc }) {
24
49
  n = Number(n) || 0
@@ -126,26 +151,28 @@ const FM_KEY = 'lo-wt-fm:'
126
151
  export function fmClosed(fp) { try { return new Set(JSON.parse(sessionStorage.getItem(FM_KEY + fp) || '[]')) } catch { return new Set() } }
127
152
  export function fmToggle(fp, key, closed) { try { const s = fmClosed(fp); if (closed) s.add(key); else s.delete(key); sessionStorage.setItem(FM_KEY + fp, JSON.stringify([...s])) } catch {} }
128
153
  const LOOK_TIP = 'Worth a close look: the walkthrough flags this file'
129
- function fmFileHtml(it, { esc, icon, cur, depth }) {
130
- return '<a class="wt-fm-i' + (it.step === cur ? ' cur' : '') + '" href="#" data-action="wt-go" data-step="' + esc(it.step) + '" data-path="' + esc(it.file) + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(it.file + ', step ' + it.n + (it.look ? ', worth a close look' : '')) + '">' +
154
+ function fmFileHtml(it, { esc, icon, cur, depth, stat }) {
155
+ const f = stat && stat.get(it.file)
156
+ return '<a class="wt-fm-i' + (it.step === cur ? ' cur' : '') + '" href="#" data-action="wt-go" data-step="' + esc(it.step) + '" data-path="' + esc(it.file) + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(it.file + ', step ' + it.n + (f ? ', ' + statusWord(f).toLowerCase() : '') + (it.look ? ', worth a close look' : '')) + '">' +
131
157
  '<span class="wt-fm-n" data-tip="Step ' + esc(String(it.n)) + ' of the walkthrough">' + it.n + '</span><span class="wt-fm-f">' + esc(it.base) + '</span>' +
132
- (it.look ? '<span class="wt-fm-x" data-tip="' + LOOK_TIP + '">' + icon('eye') + '</span>' : '') + '</a>'
158
+ (it.look ? '<span class="wt-fm-x" data-tip="' + LOOK_TIP + '">' + icon('eye') + '</span>' : '') + (f ? statusHtml(f, { esc, icon }) : '') + '</a>'
133
159
  }
134
- function fmDirHtml(d, { esc, icon, cur, depth, closed, sec }) {
160
+ function fmDirHtml(d, { esc, icon, cur, depth, closed, sec, stat, fileHtml = fmFileHtml }) {
135
161
  const key = sec + ':' + d.path
136
162
  const shut = closed.has(key)
137
163
  let h = '<div class="wt-fm-d" data-key="' + esc(key) + '"><button type="button" class="wt-fm-dr" data-action="wt-fm-dir" data-key="' + esc(key) + '" aria-expanded="' + !shut + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + '>' +
138
164
  icon(shut ? 'chevron-right' : 'chevron-down') + icon('folder') + '<span class="wt-fm-dp"' + (d.name.length > 26 ? ' data-tip="' + esc(d.path) + '"' : '') + '>' + esc(midTrunc(d.name, 26)) + '</span><span class="wt-fm-dc" aria-label="' + esc(plural(countFiles(d), 'file')) + '">' + countFiles(d) + '</span></button>'
139
165
  h += '<div class="wt-fm-dk" role="group"' + (shut ? ' hidden' : '') + '>'
140
- for (const c of d.dirs) h += fmDirHtml(c, { esc, icon, cur, depth: depth + 1, closed, sec })
141
- for (const f of d.files) h += fmFileHtml(f, { esc, icon, cur, depth: depth + 1 })
166
+ for (const c of d.dirs) h += fmDirHtml(c, { esc, icon, cur, depth: depth + 1, closed, sec, stat, fileHtml })
167
+ for (const f of d.files) h += fileHtml(f, { esc, icon, cur, depth: depth + 1, stat })
142
168
  return h + '</div></div>'
143
169
  }
144
170
  function fileMapHtml(data, { esc, icon, cur }) {
145
171
  const anyLook = (data.fileMap || []).some((sec) => sec.items.some((x) => x.look))
146
- let h = '<nav class="wt-fm" aria-label="Files in this change"><div class="wt-fm-h">Files <span class="faint">' + n0(data.stats.files) + '</span>' +
172
+ let h = '<nav class="wt-fm wt-side" aria-label="Files in this change"><div class="wt-fm-h">Files <span class="faint">' + n0(data.stats.files) + '</span>' +
147
173
  (anyLook ? '<span class="wt-fm-hint">' + icon('eye') + '= worth a close look</span>' : '') + '</div>'
148
174
  const closed = fmClosed(data.fingerprint || '')
175
+ const stat = statusMap(data)
149
176
  for (const sec of data.fileMap || []) {
150
177
  const count = sec.items.reduce((a, x) => a + (x.count || 1), 0)
151
178
  h += '<div class="wt-fm-s"><span class="wt-fm-st"><i class="wt-sw wt-bk-' + esc(sec.kind) + '" aria-hidden="true"></i>' + esc(sec.label) + '</span><span class="faint">' + count + '</span></div>'
@@ -156,10 +183,10 @@ function fileMapHtml(data, { esc, icon, cur }) {
156
183
  // one folder: say it once
157
184
  const d = t.dirs[0]
158
185
  h += '<div class="wt-fm-sub mono"' + (d.path.length > 30 ? ' data-tip="' + esc(d.path) + '"' : '') + '>' + icon('folder') + '<span>' + esc(midTrunc(d.path, 30)) + '/</span></div>'
159
- for (const f of d.files) h += fmFileHtml(f, { esc, icon, cur, depth: 0 })
186
+ for (const f of d.files) h += fmFileHtml(f, { esc, icon, cur, depth: 0, stat })
160
187
  } else {
161
- for (const d of t.dirs) h += fmDirHtml(d, { esc, icon, cur, depth: 0, closed, sec: sec.kind })
162
- for (const f of t.files) h += fmFileHtml(f, { esc, icon, cur, depth: 0 })
188
+ for (const d of t.dirs) h += fmDirHtml(d, { esc, icon, cur, depth: 0, closed, sec: sec.kind, stat })
189
+ for (const f of t.files) h += fmFileHtml(f, { esc, icon, cur, depth: 0, stat })
163
190
  }
164
191
  }
165
192
  for (const it of sec.items) {
@@ -198,7 +225,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
198
225
  '<div class="wt-ctl">' + scopesHtml(data, scope, { esc, icon, sid }) +
199
226
  '<div class="wt-seg wt-seg-sm" role="group" aria-label="View"><button type="button" data-action="wt-view" data-view="story" aria-pressed="' + (view === 'story') + '">Story</button><button type="button" data-action="wt-view" data-view="files" aria-pressed="' + (view === 'files') + '">Files</button></div>' +
200
227
  // 0.9.18 slides or one long page (kept per device)
201
- (ok && view === 'story' && (data.steps || []).length ? '<button type="button" class="btn wt-deckt" data-action="wt-deck" aria-pressed="' + (ui.deck === false) + '" data-tip="' + (ui.deck === false ? 'One step at a time, as slides' : 'Every step on one long page') + '" aria-label="' + (ui.deck === false ? 'Slides' : 'Show all') + '">' + icon(ui.deck === false ? 'layers' : 'list-checks') + '<span class="wt-deckt-l">' + (ui.deck === false ? 'Slides' : 'Show all') + '</span></button>' : '') +
228
+ // (Joel: the icon needs a tip) what a click does, and how it's showing now
229
+ (ok && view === 'story' && (data.steps || []).length ? '<button type="button" class="btn wt-deckt" data-action="wt-deck" aria-pressed="' + (ui.deck === false) + '" data-tip="' + (ui.deck === false ? 'Show as slides, one step at a time · now: all steps on one page' : 'Show all steps on one page · now: slides') + '" aria-label="' + (ui.deck === false ? 'Show as slides' : 'Show all steps on one page') + '">' + icon(ui.deck === false ? 'gallery-horizontal' : 'rows-3') + '<span class="wt-deckt-l">' + (ui.deck === false ? 'Slides' : 'Show all') + '</span></button>' : '') +
202
230
  exportsHtml(ok, ui, { icon }) + '</div>' +
203
231
  '<button type="button" class="wt-x" data-action="wt-close" data-id="' + esc(sid) + '" aria-label="Close the walkthrough">' + icon('x') + '</button></div>'
204
232
  // which model wrote it (Settings → Models → Walk me through it picks it)
@@ -253,16 +281,20 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
253
281
  groups.map((g) => groupHtml(g, { esc, icon, open: ui.groups && ui.groups.has(g.kind) })).join('')
254
282
  }
255
283
  body += '<p class="wt-cov">' + icon('circle-check') + 'Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '.</p>'
256
- } else if (data && Array.isArray(data.files) && data.files.length && !ui.loading) {
284
+ }
285
+ let fl = ''
286
+ if (!(ok && view === 'story') && data && Array.isArray(data.files) && data.files.length && !ui.loading) {
287
+ if (data.files.length > 1) { fl = filesListHtml(data, { esc, icon }); body += filesListHtml(data, { esc, icon, mobile: true }) }
257
288
  body += filesHtml(data.files, { esc, icon })
258
289
  }
259
- const fm = big ? fileMapHtml(data, { esc, icon, cur: ui.cur }) : ''
290
+ const fm = big ? fileMapHtml(data, { esc, icon, cur: ui.cur }) : fl
260
291
  const dock = '<form class="wt-ask" data-action-form="wt-ask" data-id="' + esc(sid) + '"><label class="lo-vh" for="wtAsk">Ask about this change</label><input id="wtAsk" class="wt-ask-in" type="text" autocomplete="off" placeholder="Ask about this change…" value="' + esc(ui.ask || '') + '"><button type="submit" class="wt-ask-b" aria-label="Put the question in the chat" data-tip="Into the chat box">' + icon('arrow-up') + '</button></form>'
261
292
  const pnav = ok && view === 'story' && (data.steps || []).length ? '<div class="wt-pnav"><button type="button" class="btn" data-action="wt-step" data-dir="-1">' + icon('chevron-left') + 'Back</button><span class="faint" id="wtPnavN">' + stepPos(data.steps, ui) + '</span><button type="button" class="btn" data-action="wt-step" data-dir="1">Next' + icon('chevron-right') + '</button></div>' : ''
262
293
  const dk = deck ? deckSlides(data, ui) : null
263
- return '<div class="wt' + (fm ? ' wt-big' : '') + (deck ? ' wt-deckmode' : '') + '" id="wt" data-sid="' + esc(sid) + '" role="region" aria-labelledby="wtTitle"><div class="wt-grab" aria-hidden="true"></div>' +
294
+ return '<div class="wt' + (big ? ' wt-big' : fl ? ' wt-fls' : '') + (deck ? ' wt-deckmode' : '') + '" id="wt" data-sid="' + esc(sid) + '" role="region" aria-labelledby="wtTitle"><div class="wt-grab" aria-hidden="true"></div>' +
264
295
  (deck ? '<div class="wt-dprog" aria-hidden="true"><i id="wtDprog" data-p="' + Math.round(((dk.cur + 1) / dk.slides.length) * 1000) / 10 + '"></i></div>' : '') +
265
- '<div class="wt-scroll" id="wtScroll"><div class="wt-hw">' + fm + '<div class="wt-in">' + h + body + '</div></div></div>' + (deck ? deckNavHtml(dk, { esc, icon, reviewed }) : pnav) + dock + '</div>'
296
+ // 0.9.18 (Joel): with a file list on the left, the list and the reading each scroll on their own
297
+ (fm ? '<div class="wt-2p">' + fm : '') + '<div class="wt-scroll" id="wtScroll"><div class="wt-hw"><div class="wt-in">' + h + body + '</div></div></div>' + (fm ? '</div>' : '') + (deck ? deckNavHtml(dk, { esc, icon, reviewed }) : pnav) + dock + '</div>'
266
298
  }
267
299
 
268
300
  // 0.9.18 the walkthrough as slides (Joel: "almost a slide show, each slide was
@@ -318,14 +350,14 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
318
350
  // the bar under the slides: ← Prev · the current title · numbered dots · 3 / 12 · Next →
319
351
  export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set() }) {
320
352
  const dot = (s, i) => '<button type="button" class="wt-dot' + (i === cur ? ' cur' : '') + (s.step && reviewed.has(s.step.id) ? ' done' : '') + '" data-action="wt-slide" data-slide="' + i + '"' + (s.step ? ' data-step="' + esc(s.step.id) + '"' : '') +
321
- ' aria-label="' + esc((i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + s.title : s.title) + (s.step && reviewed.has(s.step.id) ? ', reviewed' : '')) + '"' + (i === cur ? ' aria-current="step"' : '') + '>' +
353
+ ' aria-label="' + esc((i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + s.title : s.title) + (s.step && reviewed.has(s.step.id) ? ', reviewed' : '')) + '" data-tip="' + esc(i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + s.title : s.title) + '"' + (i === cur ? ' aria-current="step"' : '') + '>' +
322
354
  (i === 0 ? icon('route') : s.step ? '<span class="wt-dot-n">' + s.step.n + '</span>' : icon('layers')) + '<span class="wt-dot-ok" aria-hidden="true">' + icon('check') + '</span></button>'
323
355
  return '<nav class="wt-dnav" aria-label="Slides">' +
324
- '<button type="button" class="btn wt-dprev" data-action="wt-dstep" data-dir="-1" aria-label="Previous slide"' + (cur === 0 ? ' disabled' : '') + '>' + icon('chevron-left') + '<span class="wt-dl">Prev</span></button>' +
356
+ '<button type="button" class="btn wt-dprev" data-action="wt-dstep" data-dir="-1" aria-label="Previous slide" data-tip="Previous slide (←)"' + (cur === 0 ? ' disabled' : '') + '>' + icon('chevron-left') + '<span class="wt-dl">Prev</span></button>' +
325
357
  '<span class="wt-dnow" id="wtDnow">' + esc(slides[cur].title) + '</span>' +
326
358
  '<div class="wt-dots" id="wtDots" role="group" aria-label="Go to a slide">' + slides.map(dot).join('') + '</div>' +
327
359
  '<span class="wt-dcount" id="wtDcount" aria-live="polite">' + (cur + 1) + ' / ' + slides.length + '</span>' +
328
- '<button type="button" class="btn wt-dnext" data-action="wt-dstep" data-dir="1" aria-label="Next slide"' + (cur === slides.length - 1 ? ' disabled' : '') + '><span class="wt-dl">Next</span>' + icon('chevron-right') + '</button></nav>'
360
+ '<button type="button" class="btn wt-dnext" data-action="wt-dstep" data-dir="1" aria-label="Next slide" data-tip="Next slide (→)"' + (cur === slides.length - 1 ? ' disabled' : '') + '><span class="wt-dl">Next</span>' + icon('chevron-right') + '</button></nav>'
329
361
  }
330
362
  export function stepPos(steps, ui) {
331
363
  const vis = visibleSteps(steps || [], ui.filter)
@@ -336,12 +368,33 @@ export function stepPos(steps, ui) {
336
368
  export function filesHtml(files, { esc, icon }) {
337
369
  const open = files.length <= 10
338
370
  return '<div class="wt-files-v">' + files.map((f, i) => {
339
- const what = f.hidden ? 'not shown (keys and env files never are)' : f.binary ? 'binary file' : f.generated ? 'generated or lockfile · ' + plural(f.add + f.del, 'line') : ''
340
- return '<details class="wt-file" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + '<span class="mono wt-file-p">' + esc(f.path) + '</span>' + (f.oldPath ? '<span class="faint">from ' + esc(f.oldPath) + '</span>' : '') +
371
+ const what = f.hidden ? 'not shown (keys and env files never are)' : f.binary ? 'binary file' : f.generated ? 'generated or lockfile · ' + plural(f.add + f.del, 'line') : !f.lines ? (f.status === 'renamed' ? 'renamed, nothing changed inside' : 'no lines changed') : ''
372
+ return '<details class="wt-file" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') +
341
373
  '<span class="wt-file-n"><span class="wt-add">+' + n0(f.add) + '</span> <span class="wt-del">−' + n0(f.del) + '</span></span></summary>' +
342
374
  (what ? '<p class="faint wt-g-p">' + esc(what) + '</p>' : '<div class="wt-snip-d" data-file="' + i + '"></div>' + (f.clipped ? '<p class="faint wt-g-p">… too big to show all of it here</p>' : '')) + '</details>'
343
375
  }).join('') + '</div>'
344
376
  }
377
+ // 0.9.18 (Joel): the Files view's list of files, beside the diff on a wide screen (each
378
+ // scrolls on its own) and folded above it on a phone. Folders like the file map;
379
+ // each file with its status by shape and its +/− count; a click opens that file's diff.
380
+ function flFileHtml(it, { esc, icon, depth }) {
381
+ const f = it.f
382
+ return '<a class="wt-fm-i wt-fl-i" href="#" data-action="wt-file" data-i="' + it.i + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(f.path + ', ' + statusWord(f).toLowerCase() + ', ' + f.add + ' added, ' + f.del + ' removed') + '">' +
383
+ statusHtml(f, { esc, icon }) + '<span class="wt-fm-f">' + esc(it.base) + '</span><span class="wt-fl-n" aria-hidden="true"><span class="wt-add">+' + n0(f.add) + '</span><span class="wt-del">−' + n0(f.del) + '</span></span></a>'
384
+ }
385
+ export function filesListHtml(data, { esc, icon, mobile = false }) {
386
+ const files = (data && data.files) || []
387
+ const items = files.map((f, i) => ({ file: f.path, n: 0, i, f }))
388
+ const t = folderTree(items)
389
+ const closed = fmClosed(data.fingerprint || '')
390
+ let rows = ''
391
+ for (const d of t.dirs) rows += fmDirHtml(d, { esc, icon, depth: 0, closed, sec: 'files', fileHtml: flFileHtml })
392
+ for (const f of t.files) rows += flFileHtml(f, { esc, icon, depth: 0 })
393
+ const add = files.reduce((a, f) => a + (f.add || 0), 0), del = files.reduce((a, f) => a + (f.del || 0), 0)
394
+ const head = 'Files <span class="faint">' + n0(files.length) + '</span><span class="wt-fl-tot"><span class="wt-add">+' + n0(add) + '</span><span class="wt-del">−' + n0(del) + '</span></span>'
395
+ if (mobile) return '<details class="wt-fl-m"><summary>' + icon('chevron-right', 'ic wt-g-chev') + head + '</summary><nav class="wt-fm wt-fl" aria-label="Files in this change">' + rows + '</nav></details>'
396
+ return '<nav class="wt-fm wt-fl wt-side" aria-label="Files in this change"><div class="wt-fm-h">' + head + '</div>' + rows + '</nav>'
397
+ }
345
398
 
346
399
  // Draw the snippets (diff-view.js's renderDiff) and put each callout under its
347
400
  // line, the line marked in the gutter. Drawn nodes are kept per answer, so a