@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/CHANGELOG.md +2 -0
- package/bin/leftoff.js +28 -28
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +121 -20
- package/ui/app.js +277 -22
- package/ui/icons.js +9 -0
- package/ui/index.html +16 -12
- package/ui/live-view.js +2 -0
- package/ui/navrail.js +99 -0
- package/ui/semdiff.js +73 -20
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
}
|
|
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' + (
|
|
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
|
-
|
|
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
|