@leftoff/cli 0.9.18-beta.3 → 0.9.18-beta.5

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/semdiff.js CHANGED
@@ -142,9 +142,11 @@ export function snipsByFile(st) {
142
142
  return out
143
143
  }
144
144
  // the files of a walkthrough that start folded: generated or lockfiles, and anything in Safe to skip
145
+ // (2026-11-03 not "Tests updated to match": a test a step names, like the one that pins the bug,
146
+ // is part of that step's story even when the rest of its lines landed in that group)
145
147
  export function skipFiles(data) {
146
148
  const out = new Set()
147
- for (const g of (data && data.groups) || []) for (const f of g.files || []) out.add(f.path)
149
+ for (const g of (data && data.groups) || []) if (g.kind !== 'tests') for (const f of g.files || []) out.add(f.path)
148
150
  for (const f of (data && data.files) || []) if (f.generated) out.add(f.path)
149
151
  return out
150
152
  }
@@ -163,10 +165,58 @@ export function sfSet(fp, keys, open) {
163
165
  if (all.length > 30) for (const k of all.filter((x) => x !== SF_KEY + fp).slice(0, all.length - 30)) localStorage.removeItem(k)
164
166
  } catch {}
165
167
  }
166
- export function sfOpen(state, step, file, skip) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : fileFoldsDefault(file, skip) }
168
+ // def: the default when nothing was chosen (null: by the file's kind)
169
+ export function sfOpen(state, step, file, skip, def = null) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : def != null ? !!def : fileFoldsDefault(file, skip) }
170
+ // 2026-11-03 ("Where's my diff?": steps 1 and 2 both named one new test file; step 1's row came up
171
+ // empty and its diff showed in step 2 only). Two steps naming exactly the same lines had one id,
172
+ // so the one drawn diff moved to the later step. Walkthroughs kept before the fix still carry the
173
+ // shared id: the later ones get their count appended, as src/semdiff.js uniqueStepIds does. In place.
174
+ export function uniqueStepIds(steps) {
175
+ const seen = new Set()
176
+ for (const st of steps || []) {
177
+ if (!st || typeof st.id !== 'string' || !st.id) continue
178
+ if (!seen.has(st.id)) { seen.add(st.id); continue }
179
+ let k = 2
180
+ while (seen.has(st.id + k.toString(16))) k++
181
+ st.id = st.id + k.toString(16)
182
+ seen.add(st.id)
183
+ }
184
+ return steps
185
+ }
186
+ // The same lines shown again by a later step: "<step id>:<snippet i>" -> { id, n } of the first
187
+ // step (in reading order) that shows them. That first step shows them open; a later one folds
188
+ // that file with "Also in step N", which goes there.
189
+ export function sharedSnips(data) {
190
+ const first = new Map(), out = new Map()
191
+ for (const st of readingOrder((data && data.steps) || [], data)) {
192
+ ;(st.snippets || []).forEach((sn, i) => {
193
+ const k = sn.hunk + '\0' + sn.patch + '\0' + JSON.stringify(sn.focus || null)
194
+ const f = first.get(k)
195
+ if (!f) first.set(k, { id: st.id, n: st.n })
196
+ else if (f.id !== st.id) out.set(st.id + ':' + i, f)
197
+ })
198
+ }
199
+ return out
200
+ }
201
+ // "Lines 12–30", "Lines 3–5, 9" (old side: "Old lines …"), or '' with no focus
202
+ export function focusWords(focus) {
203
+ if (!Array.isArray(focus) || !focus.length) return ''
204
+ const r = (f) => (f.from === f.to ? String(f.from) : f.from + '–' + f.to)
205
+ const nw = focus.filter((f) => f.side !== 'old'), od = focus.filter((f) => f.side === 'old')
206
+ const parts = []
207
+ if (nw.length) parts.push((nw.length === 1 && nw[0].from === nw[0].to ? 'Line ' : 'Lines ') + nw.map(r).join(', '))
208
+ if (od.length) parts.push((parts.length ? 'old ' : 'Old ') + (od.length === 1 && od[0].from === od[0].to ? 'line ' : 'lines ') + od.map(r).join(', '))
209
+ return parts.join(', ')
210
+ }
211
+ // a file of a step whose every snippet an earlier step shows: that step, else null
212
+ export function alsoIn(st, g, shared) {
213
+ if (!shared || !shared.size || !g.snips.length) return null
214
+ const fs = g.snips.map(([, i]) => shared.get(st.id + ':' + i))
215
+ return fs.every((f) => f && f.id === fs[0].id) ? fs[0] : null
216
+ }
167
217
  export const CHANGED_TIP = 'Its files changed since this walkthrough was written'
168
218
 
169
- function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null }) {
219
+ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true }) {
170
220
  const core = st.tag === 'Core'
171
221
  // 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
172
222
  const rp = st.repo ? [st.repo] : []
@@ -178,15 +228,17 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
178
228
  '<button type="button" class="wt-rev' + (done ? ' on' : '') + '" data-action="wt-reviewed" data-id="' + esc(sid) + '" data-step="' + esc(st.id) + '" aria-pressed="' + (done ? 'true' : 'false') + '" data-tip="' + (done ? 'Reviewed · tap to undo' : 'Mark reviewed') + '">' + icon('check') + '<span>' + (done ? 'Reviewed' : 'Mark reviewed') + '</span></button></header>'
179
229
  const why = st.why ? '<div class="wt-why wt-md">' + walkBlock(st.why) + '</div>' : ''
180
230
  const byFile = snipsByFile(st)
181
- const fileHead = (g, isOpen) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed') + '">' + icon('chevron-right', 'ic wt-g-chev') +
231
+ const fileHead = (g, isOpen, also) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed' + (also ? ', also in step ' + also.n : '')) + '">' + icon('chevron-right', 'ic wt-g-chev') +
182
232
  (stat && stat.get(g.file) ? statusHtml(stat.get(g.file), { esc, icon }) : '') +
183
233
  '<span class="mono wt-sf-p">' + esc(st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + '</span>' +
234
+ (also ? '<a class="wt-also" href="#wt-' + esc(also.id) + '" data-action="wt-go" data-step="' + esc(also.id) + '" data-file="' + esc(g.file) + '" data-tip="The same lines, open in step ' + also.n + '">' + icon('arrow-up') + 'Also in step ' + also.n + '</a>' : '') +
184
235
  '<span class="wt-file-n"><span class="wt-add">+' + n0(g.add) + '</span> <span class="wt-del">−' + n0(g.del) + '</span></span></summary>'
185
236
  const snips = (byFile.length > 1 ? '<div class="wt-sfa" role="group" aria-label="Files in this step"><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="0">' + icon('chevrons-down-up') + 'Collapse all</button><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="1">' + icon('chevrons-up-down') + 'Expand all</button></div>' : '') +
186
237
  byFile.map((g) => {
187
- const isOpen = sfOpen(sf, st.id, g.file, skip)
188
- return '<details class="wt-sf" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen) +
189
- g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
238
+ const also = alsoIn(st, g, shared)
239
+ const isOpen = sfOpen(sf, st.id, g.file, skip, also && foldShared ? false : null)
240
+ return '<details class="wt-sf' + (also ? ' wt-sf-also' : '') + '" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen, also) +
241
+ g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : focusWords(sn.focus) ? '<div class="wt-snip-h"><span class="faint wt-focus-w"><i class="wt-focus-g" aria-hidden="true"></i>' + esc(focusWords(sn.focus)) + ' for this step</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
190
242
  }).join('')
191
243
  const show = core || open
192
244
  const lines = st.snippets.reduce((a, s) => a + s.changed, 0)
@@ -424,7 +476,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
424
476
  const vis = new Set(visibleSteps(steps, ui.filter).map((x) => x.id))
425
477
  const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
426
478
  const bd = data.stats.breakdown || {}
427
- const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) })
479
+ const shared = sharedSnips(data), skip = skipFiles(data), stat = statusMap(data)
480
+ const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip, stat, shared })
428
481
  const repos = walkRepos(data)
429
482
  // 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
430
483
  if (repos.length) {
@@ -481,6 +534,7 @@ export function deckSlides(data, ui = {}) {
481
534
  function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
482
535
  const { slides, cur } = deckSlides(data, ui)
483
536
  const steps = data.steps || []
537
+ const shared = sharedSnips(data)
484
538
  const total = slides.length
485
539
  const sec = (i, inner, label, extra = '') => '<section class="wt-slide' + (i === cur ? ' on' : '') + '" data-slide="' + i + '"' + extra + ' role="group" aria-roledescription="slide" aria-label="' + esc(label) + '"' + (i === cur ? '' : ' hidden') + '>' + inner + '</section>'
486
540
  let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
@@ -511,7 +565,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
511
565
  slides.forEach((s, i) => {
512
566
  if (s.kind === 'step') {
513
567
  const x = s.step
514
- h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
568
+ h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen */ }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
515
569
  } else if (s.kind === 'skip') {
516
570
  const groups = data.groups || []
517
571
  const n = groups.reduce((a, g) => a + g.files.length + (g.kind === 'repeated' ? 1 : 0), 0), l = groups.reduce((a, g) => a + g.lines, 0)
@@ -649,7 +703,7 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
649
703
  if (!cache) { cache = new Map(); drawnFor.set(data, cache) }
650
704
  const doc = root.ownerDocument
651
705
  const sp = !!(split && renderSplit)
652
- const draw = (key, patch, label, callouts) => {
706
+ const draw = (key, patch, label, callouts, focus = null) => {
653
707
  const ck = (sp ? 'S|' : 'U|') + key
654
708
  let node = cache.get(ck)
655
709
  if (!node) {
@@ -661,6 +715,15 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
661
715
  node.classList.add('wt-gd')
662
716
  node.dataset.wtk = key
663
717
  for (const g of node.querySelectorAll('.gd-gap')) g.remove()
718
+ // 2026-11-03 the lines this step's refs name, when it shows a hunk whole (a new file two steps share)
719
+ for (const f of Array.isArray(focus) ? focus : []) {
720
+ const sel = f.side === 'old' ? '.gd-o' : '.gd-n'
721
+ for (const el of node.querySelectorAll(sp ? '.gd-half' : '.gd-row')) {
722
+ const x = el.querySelector(sel), v = x && x.textContent !== '' ? Number(x.textContent) : NaN
723
+ if (v >= f.from && v <= f.to) el.classList.add('wt-focus')
724
+ }
725
+ }
726
+ if (Array.isArray(focus) && focus.length) node.classList.add('wt-gd-focus')
664
727
  for (const c of callouts || []) {
665
728
  const sel = c.side === 'old' ? '.gd-o' : '.gd-n'
666
729
  let row = null
@@ -692,8 +755,10 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
692
755
  const [sid, i] = slot.getAttribute('data-snip').split(':')
693
756
  const st = (data.steps || []).find((x) => x.id === sid)
694
757
  const sn = st && st.snippets[+i]
695
- if (!sn) continue
696
- slot.appendChild(draw('s:' + sid + ':' + i, sn.patch, 'Changes in ' + sn.file, st.callouts.filter((c) => c.hunk === sn.hunk))); k++
758
+ if (!sn) { slot.innerHTML = '<p class="faint wt-snip-none">No lines to show here.</p>'; continue } // never an empty row
759
+ const node = draw('s:' + sid + ':' + i, sn.patch, 'Changes in ' + sn.file, st.callouts.filter((c) => c.hunk === sn.hunk), sn.focus)
760
+ if (node.isConnected && root.contains(node) && !slot.contains(node)) { slot.appendChild(node.cloneNode(true)); k++; continue } // shown in another slot on this page: a copy, never moved out of it
761
+ slot.appendChild(node); k++
697
762
  }
698
763
  for (const slot of root.querySelectorAll('[data-group-ex]')) {
699
764
  if (slot.firstChild) continue