@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/CHANGELOG.md +5 -0
- package/bin/leftoff.js +548 -531
- package/bin/pty-host-main.js +10 -10
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +141 -4
- package/package.json +1 -1
- package/ui/app.css +55 -0
- package/ui/app.js +32 -6
- package/ui/hook-fix.js +109 -0
- package/ui/semdiff.js +77 -12
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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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
|