@leftoff/cli 0.9.17-beta.8 → 0.9.17
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 +21 -1
- package/bin/leftoff.js +484 -474
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +221 -2
- package/ui/app.js +440 -53
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/icons.js +4 -0
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +282 -54
- package/ui/session-catchup.js +5 -1
- package/ui/side-fold.js +124 -0
- package/ui/walk-lines.js +119 -0
- package/ui/walk-md.js +40 -0
- package/ui/walk-pop.js +24 -5
- package/ui/walk-spots.js +89 -0
package/ui/session-catchup.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// The recap's lines come from the transcript, so they go in as text afterwards
|
|
7
7
|
// ([data-scu], fillSessionCatchup), never as markup.
|
|
8
8
|
import { copyButtonHtml, fillCopy } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
|
|
9
|
+
import { mdInline } from './changelog-md.js'
|
|
10
|
+
const SCU_MD = /^(goal|next|b-\d+)$/
|
|
9
11
|
|
|
10
12
|
export function catchupButtonHtml(s, { esc, icon = () => '', busy = false } = {}) {
|
|
11
13
|
if (!s || s.agent || String(s.id || '').startsWith('agent:')) return ''
|
|
@@ -107,7 +109,9 @@ export function fillSessionCatchup(root, ui) {
|
|
|
107
109
|
if (!root || !root.querySelectorAll) return 0
|
|
108
110
|
const t = sessionCatchupTexts(ui)
|
|
109
111
|
let n = 0
|
|
110
|
-
|
|
112
|
+
// 0.9.19: the recap's own words (goal, lines, Next) render `code` and **bold**: mdInline escapes
|
|
113
|
+
// everything first, so a line is still never markup of its own; the rest stays plain text
|
|
114
|
+
for (const el of root.querySelectorAll('[data-scu]')) { const k = el.getAttribute('data-scu'), v = t[k] || ''; if (SCU_MD.test(k)) el.innerHTML = mdInline(v, { links: false }); else el.textContent = v; n++ }
|
|
111
115
|
const by = root.querySelector('[data-scu="by"]')
|
|
112
116
|
if (by && !by.textContent) by.hidden = true
|
|
113
117
|
const cb = root.querySelector('.scu .msg-copy') // 0.9.11: Copy's words, as an attribute
|
package/ui/side-fold.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 2026-10-29 A calmer session panel (Joel: "very noisy and SUPER SCROLLY").
|
|
2
|
+
// The top stays open (the head, Catch me up / Walk, the newest checkpoint's
|
|
3
|
+
// Stopped + Next, PRs, To do); everything else is a one-line header with a
|
|
4
|
+
// count ("Files · 12"), closed until you open it. Open/closed is kept per
|
|
5
|
+
// device (localStorage lo-side-fold-v1), not per session. Pure builders,
|
|
6
|
+
// unit-tested (test/unit/calmside-1029.test.mjs); app.js wires them.
|
|
7
|
+
|
|
8
|
+
export const FOLD_KEY = 'lo-side-fold-v1'
|
|
9
|
+
|
|
10
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
11
|
+
|
|
12
|
+
// { key: true | false } in one key; anything unreadable is "nothing chosen yet"
|
|
13
|
+
export function createFoldStore(storage) {
|
|
14
|
+
const read = () => { try { const o = JSON.parse((storage && storage.getItem(FOLD_KEY)) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {} } catch { return {} } }
|
|
15
|
+
return {
|
|
16
|
+
open(key, dflt = false) { const v = read()[key]; return typeof v === 'boolean' ? v : !!dflt },
|
|
17
|
+
set(key, open) {
|
|
18
|
+
if (!key || !/^[\w.-]{1,40}$/.test(key)) return
|
|
19
|
+
const o = read(); o[key] = !!open
|
|
20
|
+
try { storage && storage.setItem(FOLD_KEY, JSON.stringify(o)) } catch {}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A section as one line: "Files · 12", a chevron; its body under it when open.
|
|
26
|
+
// body: the section's own markup (it keeps its id, so its painter still works).
|
|
27
|
+
export function foldHtml({ key, title, count = null, body = '', open = false, cls = '' }, { esc = escD } = {}) {
|
|
28
|
+
if (!body) return ''
|
|
29
|
+
const n = count == null || count === '' ? '' : '<span class="fs-fold-n"> · ' + esc(String(count)) + '</span>'
|
|
30
|
+
return '<details class="fs-fold' + (cls ? ' ' + cls : '') + '" data-fold="' + esc(key) + '"' + (open ? ' open' : '') + '>' +
|
|
31
|
+
'<summary class="fs-fold-h"><span class="fs-fold-t">' + esc(title) + n + '</span><svg class="fs-fold-c" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></summary>' +
|
|
32
|
+
'<div class="fs-fold-b">' + body + '</div></details>'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------ checkpoints
|
|
36
|
+
|
|
37
|
+
// A path as the reader knows it: relative to the session's repo / worktree root,
|
|
38
|
+
// else ~/ for the home folder. /Users/<u>/…/worktrees/<name>/src/a.js -> src/a.js
|
|
39
|
+
export function relPath(p, { roots = [], home = '' } = {}) {
|
|
40
|
+
let s = String(p == null ? '' : p).trim()
|
|
41
|
+
if (!s) return ''
|
|
42
|
+
const rs = roots.filter((r) => typeof r === 'string' && r.length > 1).map((r) => r.replace(/\/+$/, '')).sort((a, b) => b.length - a.length)
|
|
43
|
+
for (const r of rs) if (s === r) return '.'; else if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
44
|
+
// a worktree folder of a tool (…/worktrees/<name>/rest): the rest
|
|
45
|
+
const wt = /^(?:\/Users\/[^/]+|\/home\/[^/]+|~)\/(?:[^/]+\/)*?(?:\.?worktrees|\.claude\/worktrees)\/[^/]+\/(.+)$/.exec(s)
|
|
46
|
+
if (wt) return wt[1]
|
|
47
|
+
const h = String(home || '').replace(/\/+$/, '')
|
|
48
|
+
if (h && s.startsWith(h + '/')) return '~/' + s.slice(h.length + 1)
|
|
49
|
+
const mh = /^\/(?:Users|home)\/[^/]+\/(.+)$/.exec(s)
|
|
50
|
+
if (mh) return '~/' + mh[1]
|
|
51
|
+
return s
|
|
52
|
+
}
|
|
53
|
+
// The roots a session's paths are relative to (worktree first, then repo, then its folder)
|
|
54
|
+
export function rootsOf(s) {
|
|
55
|
+
const r = (s && s.repo) || {}
|
|
56
|
+
return [s && s.worktree && s.worktree.path, r.worktree, r.root, s && s.cwd].filter((x) => typeof x === 'string' && x.startsWith('/'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Files touched as chips: the first five, then "+N more" that opens the rest.
|
|
60
|
+
export const FILES_SHOWN = 5
|
|
61
|
+
export function filesTouchedHtml(files, { roots = [], home = '' } = {}, { esc = escD } = {}) {
|
|
62
|
+
const list = (Array.isArray(files) ? files : []).map((f) => String(f || '')).filter(Boolean)
|
|
63
|
+
if (!list.length) return ''
|
|
64
|
+
const chip = (f) => '<span class="chip mono" data-tip="' + esc(f) + '">' + esc(relPath(f, { roots, home })) + '</span>'
|
|
65
|
+
const first = list.slice(0, FILES_SHOWN), rest = list.slice(FILES_SHOWN)
|
|
66
|
+
return '<div class="files">' + first.map(chip).join('') +
|
|
67
|
+
(rest.length ? '<details class="files-more"><summary class="linkish">+' + rest.length + ' more</summary>' + rest.map(chip).join('') + '</details>' : '') + '</div>'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Text a checkpoint was written from that wasn't a prompt: Claude Code's
|
|
71
|
+
// wrappers (<task-notification>, <system-reminder>, a slash command's output…),
|
|
72
|
+
// the same list the mod skips (mod/hooks/core.js NOT_A_PROMPT).
|
|
73
|
+
const WRAPPER = /^\s*(?:Working on:\s*[“"]\s*)?(?:<(?:local-command-stdout|local-command-stderr|command-name|command-message|command-args|local-command-caveat|system-reminder|bash-input|bash-stdout|bash-stderr|user-prompt-submit-hook|task-notification|user-memory-input)\b|\[Request interrupted by user|Caveat: The messages below were generated)/
|
|
74
|
+
export function isWrapperCp(cp) {
|
|
75
|
+
if (!cp) return false
|
|
76
|
+
return WRAPPER.test(String(cp.doing || '')) || (!String(cp.doing || '').trim() && WRAPPER.test(String(cp.stoppedAt || '')))
|
|
77
|
+
}
|
|
78
|
+
// Junk: a wrapper checkpoint written within a few minutes of another one (the
|
|
79
|
+
// same moment twice). A lone one stays: it's all there is.
|
|
80
|
+
export const JUNK_NEAR_MS = 5 * 60e3
|
|
81
|
+
export function dropJunkCps(list) {
|
|
82
|
+
const all = Array.isArray(list) ? list : []
|
|
83
|
+
const t = (cp) => Date.parse((cp && cp.createdAt) || '')
|
|
84
|
+
return all.filter((cp) => !(isWrapperCp(cp) && all.some((o) => o !== cp && !isWrapperCp(o) && Math.abs(t(o) - t(cp)) <= JUNK_NEAR_MS)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------ status history
|
|
88
|
+
|
|
89
|
+
// One small shape per change, the word and time in its tip (shape + word, never color alone).
|
|
90
|
+
export const STATUS_SHAPE = { needs_input: '▲', active: '●', background: '◐', waiting: '◆', dormant: '○', stale: '◌', ended: '■' }
|
|
91
|
+
export function statusStripHtml(changes, { label = {}, clock = (x) => x } = {}, { esc = escD } = {}) {
|
|
92
|
+
const list = (Array.isArray(changes) ? changes : []).slice(-24)
|
|
93
|
+
if (!list.length) return ''
|
|
94
|
+
return '<div class="sstrip" role="list" aria-label="Status history">' + list.map((c) => {
|
|
95
|
+
const w = label[c.status] || c.status || ''
|
|
96
|
+
const tip = w + ' · ' + clock(c.at)
|
|
97
|
+
return '<span class="ss s-' + esc(c.status) + '" role="listitem" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(tip) + '">' + esc(STATUS_SHAPE[c.status] || '·') + '</span>'
|
|
98
|
+
}).join('') + '</div>'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------ answered from the board
|
|
102
|
+
|
|
103
|
+
// Where an answer came from, in the words of what actually happened. Older
|
|
104
|
+
// answers said "over Tailscale" for anything from another device, the relay
|
|
105
|
+
// included (src/answers.js whereFrom before 2026-10-29); they read right now.
|
|
106
|
+
export function whereWords(w) {
|
|
107
|
+
let s = String(w || '').trim()
|
|
108
|
+
if (!s) return ''
|
|
109
|
+
const old = /^over Tailscale(?: as (.+?))?(?: \(through the relay\))?(?: \(relay\))?(?: · (.+))?$/.exec(s)
|
|
110
|
+
if (old && /\(through the relay\)|\(relay\)/.test(s)) return 'from ' + (old[1] || 'a paired device') + ' · via relay' + (old[2] ? ' · ' + old[2] : '')
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
export const ANSWERED_SHOWN = 3
|
|
114
|
+
// The newest three, "Show all N" for the rest (a <details>, so it stays put through redraws)
|
|
115
|
+
export function answeredHtml(answers, { clock = (x) => x } = {}, { esc = escD } = {}) {
|
|
116
|
+
const list = (Array.isArray(answers) ? answers : []).slice().reverse()
|
|
117
|
+
if (!list.length) return ''
|
|
118
|
+
const row = (a) => '<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
|
|
119
|
+
'<span class="where">' + esc(whereWords(a.where)) + (a.delivered === false ? ' · not picked up' : '') + '</span></li>'
|
|
120
|
+
const first = list.slice(0, ANSWERED_SHOWN), rest = list.slice(ANSWERED_SHOWN)
|
|
121
|
+
return '<ul class="answered">' + first.map(row).join('') + '</ul>' +
|
|
122
|
+
(rest.length ? '<details class="answered-more"><summary class="linkish">Show all ' + list.length + '</summary><ul class="answered">' + rest.map(row).join('') + '</ul></details>' : '') +
|
|
123
|
+
'<div class="hint">Also in answers.md in this session’s folder.</div>'
|
|
124
|
+
}
|
package/ui/walk-lines.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// 2026-10-30 (walkthrough round 3) Line references for "Ask about this change":
|
|
2
|
+
// click a line number in a walkthrough's diff to pick it, shift-click or drag
|
|
3
|
+
// for a range, Cmd/Ctrl-click to add another range (another file, another
|
|
4
|
+
// step). On a phone: tap to pick, tap another line of the same snippet to
|
|
5
|
+
// stretch it, tap in another snippet to add one. The picks show as chips over
|
|
6
|
+
// the ask box; asking puts each ref and its exact lines into the chat box.
|
|
7
|
+
//
|
|
8
|
+
// A pick is a run of rows of ONE drawn patch (a step's snippet, a file in the
|
|
9
|
+
// Files view), by row index into diff-view.js's parsePatch rows, so it means
|
|
10
|
+
// the same lines in the unified and the split view. Pure: no DOM here.
|
|
11
|
+
import { parsePatch } from './diff-view.js'
|
|
12
|
+
|
|
13
|
+
const rowsCache = new Map()
|
|
14
|
+
// patch -> parsePatch(patch).rows (kept a little while: the same patch is asked for often)
|
|
15
|
+
export function rowsOf(patch) {
|
|
16
|
+
const k = String(patch == null ? '' : patch)
|
|
17
|
+
let r = rowsCache.get(k)
|
|
18
|
+
if (!r) { r = parsePatch(k).rows; rowsCache.set(k, r); if (rowsCache.size > 300) rowsCache.delete(rowsCache.keys().next().value) }
|
|
19
|
+
return r
|
|
20
|
+
}
|
|
21
|
+
const isLine = (r) => r && (r.k === 'add' || r.k === 'del' || r.k === 'ctx')
|
|
22
|
+
|
|
23
|
+
// The numbers a run of rows covers: the new side when any row has a new number
|
|
24
|
+
// (what the file reads now), else the old side (only removed lines).
|
|
25
|
+
export function rangeInfo(rows, a, b) {
|
|
26
|
+
const lo = Math.min(a, b), hi = Math.max(a, b)
|
|
27
|
+
const ls = (rows || []).slice(lo, hi + 1).filter(isLine)
|
|
28
|
+
if (!ls.length) return null
|
|
29
|
+
const ns = ls.filter((r) => r.n != null).map((r) => r.n)
|
|
30
|
+
const side = ns.length ? 'new' : 'old'
|
|
31
|
+
const xs = side === 'new' ? ns : ls.map((r) => r.o).filter((x) => x != null)
|
|
32
|
+
return { side, from: Math.min(...xs), to: Math.max(...xs), lines: ls.length }
|
|
33
|
+
}
|
|
34
|
+
// "src/x.js:12–18", "repo · src/x.js:40", "src/x.js:7–9 (old)" for removed lines only.
|
|
35
|
+
// label: the path as a person reads it (semdiff.js pathLabel)
|
|
36
|
+
export function refLabel(label, info) {
|
|
37
|
+
if (!info) return String(label || '')
|
|
38
|
+
return String(label || '') + ':' + (info.from === info.to ? info.from : info.from + '–' + info.to) + (info.side === 'old' ? ' (old)' : '')
|
|
39
|
+
}
|
|
40
|
+
// The exact lines, the way the walkthrough's model reads them: "+ 13 │ text",
|
|
41
|
+
// "- o12 │ text" (removed, its old number), " 14 │ text" (unchanged).
|
|
42
|
+
export function refLinesText(rows, a, b, { max = 80 } = {}) {
|
|
43
|
+
const lo = Math.min(a, b), hi = Math.max(a, b)
|
|
44
|
+
const ls = (rows || []).slice(lo, hi + 1).filter(isLine)
|
|
45
|
+
const out = ls.slice(0, max).map((r) => (r.k === 'add' ? '+ ' + r.n : r.k === 'del' ? '- o' + r.o : ' ' + r.n) + ' │ ' + r.t)
|
|
46
|
+
if (ls.length > max) out.push(' … ' + (ls.length - max) + ' more lines')
|
|
47
|
+
return out.join('\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------- picking
|
|
51
|
+
// A pick: { k (which drawn patch), file, step, x (where it started), a, b }.
|
|
52
|
+
// ev: { shift, add (Cmd/Ctrl), touch } -> the new list (never the same array).
|
|
53
|
+
const inside = (p, ri) => ri >= p.a && ri <= p.b
|
|
54
|
+
export function mergePicks(list) {
|
|
55
|
+
const out = []
|
|
56
|
+
for (const p of list) {
|
|
57
|
+
const q = out.find((o) => o.k === p.k && p.a <= o.b + 1 && p.b >= o.a - 1)
|
|
58
|
+
if (q) { q.a = Math.min(q.a, p.a); q.b = Math.max(q.b, p.b) } else out.push({ ...p })
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
export function pickLine(list, at, ev = {}) {
|
|
63
|
+
const cur = (list || []).map((p) => ({ ...p }))
|
|
64
|
+
const one = { k: at.k, file: at.file, step: at.step || null, x: at.ri, a: at.ri, b: at.ri }
|
|
65
|
+
const lastOf = () => { for (let i = cur.length - 1; i >= 0; i--) if (cur[i].k === at.k) return i; return -1 }
|
|
66
|
+
if (ev.shift) {
|
|
67
|
+
const i = lastOf()
|
|
68
|
+
if (i < 0) return mergePicks([...cur, one])
|
|
69
|
+
const p = cur[i]; p.a = Math.min(p.x, at.ri); p.b = Math.max(p.x, at.ri)
|
|
70
|
+
return mergePicks(cur)
|
|
71
|
+
}
|
|
72
|
+
if (ev.add) {
|
|
73
|
+
const i = cur.findIndex((p) => p.k === at.k && inside(p, at.ri))
|
|
74
|
+
if (i >= 0) { cur.splice(i, 1); return cur }
|
|
75
|
+
return mergePicks([...cur, one])
|
|
76
|
+
}
|
|
77
|
+
if (ev.touch) {
|
|
78
|
+
const i = cur.findIndex((p) => p.k === at.k && inside(p, at.ri))
|
|
79
|
+
if (i >= 0) { cur.splice(i, 1); return cur } // a tap on a picked line lets it go
|
|
80
|
+
const j = lastOf()
|
|
81
|
+
if (j >= 0 && j === cur.length - 1) { const p = cur[j]; p.a = Math.min(p.x, at.ri); p.b = Math.max(p.x, at.ri); return mergePicks(cur) } // a second tap stretches it
|
|
82
|
+
return mergePicks([...cur, one])
|
|
83
|
+
}
|
|
84
|
+
if (cur.length === 1 && cur[0].k === at.k && cur[0].a === at.ri && cur[0].b === at.ri) return []
|
|
85
|
+
return [one]
|
|
86
|
+
}
|
|
87
|
+
// dragging: the pick at index i stretched from where it started to ri
|
|
88
|
+
export function dragTo(list, i, ri) {
|
|
89
|
+
const cur = (list || []).map((p) => ({ ...p }))
|
|
90
|
+
const p = cur[i]
|
|
91
|
+
if (!p) return cur
|
|
92
|
+
p.a = Math.min(p.x, ri); p.b = Math.max(p.x, ri)
|
|
93
|
+
return cur
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- asking
|
|
97
|
+
// picks + how to read each one -> [{ label, text, step }] ; read(p) -> { rows, label, stepTitle }
|
|
98
|
+
export function describePicks(list, read) {
|
|
99
|
+
const out = []
|
|
100
|
+
for (const p of list || []) {
|
|
101
|
+
const r = read(p)
|
|
102
|
+
if (!r || !r.rows) continue
|
|
103
|
+
const info = rangeInfo(r.rows, p.a, p.b)
|
|
104
|
+
if (!info) continue
|
|
105
|
+
out.push({ label: refLabel(r.label, info), text: refLinesText(r.rows, p.a, p.b), stepTitle: r.stepTitle || '', info })
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
// The words that go into the chat box: each ref with its lines in a diff
|
|
110
|
+
// fence, then the question. refs: describePicks' answer.
|
|
111
|
+
export function refsPrompt(refs, question) {
|
|
112
|
+
const q = String(question || '').trim()
|
|
113
|
+
if (!refs || !refs.length) return q
|
|
114
|
+
const blocks = refs.map((r) => {
|
|
115
|
+
const fence = /```/.test(r.text) ? '~~~~' : '```'
|
|
116
|
+
return '`' + r.label + '`' + (r.stepTitle ? ' (' + r.stepTitle + ')' : '') + ':\n' + fence + 'diff\n' + r.text + '\n' + fence
|
|
117
|
+
})
|
|
118
|
+
return (refs.length === 1 ? 'About these lines:' : 'About these ' + refs.length + ' places:') + '\n\n' + blocks.join('\n\n') + (q ? '\n\n' + q : '')
|
|
119
|
+
}
|
package/ui/walk-md.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 0.9.19 (Joel: "Should we rendering md in the walk through? (and when
|
|
2
|
+
// downloaded)"): a walkthrough's words are Markdown the model wrote, so they
|
|
3
|
+
// render the way the board renders Claude's replies: the same renderer
|
|
4
|
+
// (ui/handoffs.js renderDoc, ui/changelog-md.js mdInline), which escapes
|
|
5
|
+
// everything first (raw HTML shows as text and never runs) and makes a link
|
|
6
|
+
// only for https (new tab, rel=noopener). Titles are inline only (code, bold,
|
|
7
|
+
// italic; no links, since a title sits in buttons and tips); a step's why gets
|
|
8
|
+
// paragraphs, lists and fenced code too. The export uses the same HTML with no
|
|
9
|
+
// copy buttons (it has no script). Copy as Markdown stays the raw text.
|
|
10
|
+
import { mdInline } from './changelog-md.js'
|
|
11
|
+
import { renderDoc } from './handoffs.js'
|
|
12
|
+
import { codeCopyButton } from './code-copy.js'
|
|
13
|
+
|
|
14
|
+
// a title: inline only
|
|
15
|
+
export function walkInline(s) { return mdInline(String(s == null ? '' : s), { links: false, em: true }) }
|
|
16
|
+
// a callout or the summary: inline, with https links
|
|
17
|
+
export function walkInlineLinks(s) { return mdInline(String(s == null ? '' : s), { links: true, em: true }) }
|
|
18
|
+
// The export links out to nothing (one offline file): a link there is its words,
|
|
19
|
+
// with the address after them in brackets when the words aren't the address.
|
|
20
|
+
export function unlink(html) {
|
|
21
|
+
return String(html).replace(/<a href="([^"]*)"(?: target="_blank" rel="noopener")?>([\s\S]*?)<\/a>/g, (_, href, inner) =>
|
|
22
|
+
inner.replace(/<[^>]+>/g, '') === href || inner.replace(/<[^>]+>/g, '') === href.replace(/&/g, '&') ? '<span class="u">' + inner + '</span>' : inner + ' <span class="u">(' + href + ')</span>')
|
|
23
|
+
}
|
|
24
|
+
// a step's why: blocks (paragraphs, lists, fenced code), no file links
|
|
25
|
+
export function walkBlock(s, { exported = false } = {}) {
|
|
26
|
+
const h = renderDoc(String(s == null ? '' : s), { files: false })
|
|
27
|
+
return exported ? unlink(h.split(codeCopyButton()).join('')) : h
|
|
28
|
+
}
|
|
29
|
+
// the export's inline words: rendered, nothing linked
|
|
30
|
+
export function walkInlineExport(s) { return unlink(walkInlineLinks(s)) }
|
|
31
|
+
// the same words as plain text, for a data-tip, an aria-label or the deck's "now" line
|
|
32
|
+
export function mdPlain(s) {
|
|
33
|
+
return String(s == null ? '' : s)
|
|
34
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
35
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
36
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
37
|
+
.replace(/~~(.+?)~~/g, '$1')
|
|
38
|
+
.replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1$2')
|
|
39
|
+
.replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1$2')
|
|
40
|
+
}
|
package/ui/walk-pop.js
CHANGED
|
@@ -15,20 +15,39 @@ export function sizeWords(z) {
|
|
|
15
15
|
if (z.files === 0) return { kind: 'none', text: 'No changes yet' }
|
|
16
16
|
return { kind: 'some', text: plural(z.files, 'file') + ' · +' + n0(z.add) + ' −' + n0(z.del) }
|
|
17
17
|
}
|
|
18
|
-
export function sizeLineHtml(z, { esc, icon }) {
|
|
18
|
+
export function sizeLineHtml(z, { esc, icon, reviewed = null }) {
|
|
19
19
|
const w = sizeWords(z)
|
|
20
20
|
if (!w) return ''
|
|
21
|
-
return w.kind === 'none'
|
|
21
|
+
return (w.kind === 'none'
|
|
22
22
|
? '<span class="fs-wt-meta none">' + icon('circle-dashed') + esc(w.text) + '</span>'
|
|
23
|
-
: '<span class="fs-wt-meta">' + esc(w.text) + '</span>'
|
|
23
|
+
: '<span class="fs-wt-meta">' + esc(w.text) + '</span>') + walkBadgeHtml(walkProgress(z, reviewed), { esc, icon })
|
|
24
|
+
}
|
|
25
|
+
// 2026-10-30 (asked: "if you've gone through a walk through, should we have a way of denoting that
|
|
26
|
+
// in the right sidebar where that button is?"): the walkthrough you last opened for this scope
|
|
27
|
+
// (change-size's walk: { fp, steps, changed }) and how many of its steps you marked reviewed ->
|
|
28
|
+
// null (never opened, or nothing marked yet) | { kind: 'part' | 'done', text, tip }
|
|
29
|
+
export function walkProgress(z, reviewed) {
|
|
30
|
+
const wk = z && z.walk
|
|
31
|
+
const n = wk && Number(wk.steps)
|
|
32
|
+
const r = Math.max(0, Math.min(n || 0, Number(reviewed) || 0))
|
|
33
|
+
if (!n || !r) return null
|
|
34
|
+
const ch = wk.changed > 0 ? wk.changed + (wk.changed === 1 ? ' file' : ' files') + ' changed since' : ''
|
|
35
|
+
const done = r >= n
|
|
36
|
+
const text = (done ? 'Reviewed' : r + ' of ' + n + ' reviewed') + (ch ? ' · ' + ch : '')
|
|
37
|
+
const tip = (done ? 'You marked every step of this walkthrough reviewed' : 'You marked ' + r + ' of its ' + n + ' steps reviewed') + (ch ? '; ' + ch + ' it was written' : '')
|
|
38
|
+
return { kind: done ? 'done' : 'part', text, tip }
|
|
39
|
+
}
|
|
40
|
+
export function walkBadgeHtml(p, { esc, icon }) {
|
|
41
|
+
if (!p) return ''
|
|
42
|
+
return '<span class="fs-wt-rv fs-wt-rv-' + p.kind + '" data-tip="' + esc(p.tip) + '">' + (p.kind === 'done' ? icon('check') : '<i class="wt-chg-g" aria-hidden="true"></i>') + '<span>' + esc(p.text) + '</span></span>'
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
// the button (and, on touch, its (i)); data-pop names the popover it explains itself with
|
|
27
|
-
export function walkButtonHtml(s, { esc, icon, on = false, size = null }) {
|
|
46
|
+
export function walkButtonHtml(s, { esc, icon, on = false, size = null, reviewed = null }) {
|
|
28
47
|
const id = esc(s.id)
|
|
29
48
|
return '<span class="fs-wt-wrap">' +
|
|
30
49
|
'<button type="button" class="btn fs-wt' + (on ? ' on' : '') + (sizeWords(size) && sizeWords(size).kind === 'none' ? ' none' : '') + '" data-action="' + (on ? 'wt-close' : 'wt-open') + '" data-id="' + id + '" aria-pressed="' + on + '" data-pop="wt">' +
|
|
31
|
-
icon('git-compare') + '<span class="fs-wt-l"><span class="fs-wt-v">Walk me through it</span><span class="fs-wt-size" data-id="' + id + '">' + sizeLineHtml(size, { esc, icon }) + '</span></span></button>' +
|
|
50
|
+
icon('git-compare') + '<span class="fs-wt-l"><span class="fs-wt-v">Walk me through it</span><span class="fs-wt-size" data-id="' + id + '">' + sizeLineHtml(size, { esc, icon, reviewed }) + '</span></span></button>' +
|
|
32
51
|
'<button type="button" class="fs-pop-i" data-pop-open="wt" data-id="' + id + '" aria-label="What Walk me through it does" aria-expanded="false">' + icon('info') + '</button>' +
|
|
33
52
|
'</span>'
|
|
34
53
|
}
|
package/ui/walk-spots.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 2026-10-30 (walkthrough round 3) "Next spot to really look at": the button in
|
|
2
|
+
// the walkthrough's toolbar (and the n key) jumps to the next place that
|
|
3
|
+
// deserves a real look, across steps, in reading order: the lines a callout
|
|
4
|
+
// flags, the spots the model named (src/semdiff.js validateSpots), and, when
|
|
5
|
+
// it named none, this file's heuristic: each Core snippet, plus risky-looking
|
|
6
|
+
// changes anywhere (removed logic, auth and permissions, config and
|
|
7
|
+
// migrations, error handling). Supporting snippets only when nothing else is
|
|
8
|
+
// left. Each spot carries a short reason, shown as a tag at the line. Pure.
|
|
9
|
+
import { parsePatch } from './diff-view.js'
|
|
10
|
+
|
|
11
|
+
export const SPOT_WORDS = { look: 'Look closely', assumption: 'Assumption', untested: 'Untested' }
|
|
12
|
+
const AUTH_WORDS = ['auth', 'permission', 'role', 'admin', 'token', 'secret', 'password', 'passwd', 'csrf', 'session', 'login', 'logout', 'acl', 'grant', 'privilege', 'sudo', 'jwt', 'oauth', 'cookie', 'signature', 'verify']
|
|
13
|
+
// a word on its own or the start of a camelCase part ("isAdmin", "checkPermission", "AUTH_KEY")
|
|
14
|
+
const AUTH = new RegExp('(?:(?<![A-Za-z])(?:' + AUTH_WORDS.join('|') + ')|(?<![A-Za-z])(?:' + AUTH_WORDS.map((x) => x.toUpperCase()).join('|') + ')|(?<=[a-z])(?:' + AUTH_WORDS.map((x) => x[0].toUpperCase() + x.slice(1)).join('|') + ')|(?<![A-Za-z])(?:' + AUTH_WORDS.map((x) => x[0].toUpperCase() + x.slice(1)).join('|') + '))')
|
|
15
|
+
const ERR = /\b(catch|throw|throws|raise|except|finally|reject|rescue|panic|recover|try)\b|\.catch\(|\berr(or)?s?\b/i
|
|
16
|
+
const MIGRATION = /(^|\/)(migrations?|migrate|db\/schema)(\/|\.)|\.sql$/i
|
|
17
|
+
const CONFIG = /(^|\/)(config|settings|\.github\/workflows)(\/|\.|$)|\.(ya?ml|toml|ini|conf)$|(^|\/)(Dockerfile|package\.json|\.env\.example|wrangler\.toml|tsconfig\.json)$/i
|
|
18
|
+
const COMMENT = /^\s*(\/\/|#|\*|\/\*|--|;)/
|
|
19
|
+
|
|
20
|
+
const isLine = (r) => r.k === 'add' || r.k === 'del' || r.k === 'ctx'
|
|
21
|
+
const sideOf = (r) => (r.k === 'del' ? 'old' : 'new')
|
|
22
|
+
const numOf = (r) => (r.k === 'del' ? r.o : r.n)
|
|
23
|
+
|
|
24
|
+
// One snippet (a patch, its file) -> { why, side, line, risk } or null when nothing in it changed.
|
|
25
|
+
export function snippetRisk(patch, file) {
|
|
26
|
+
const rows = parsePatch(patch).rows
|
|
27
|
+
const ch = rows.filter((r) => r.k === 'add' || r.k === 'del')
|
|
28
|
+
if (!ch.length) return null
|
|
29
|
+
const at = (r, why, risk = true) => ({ why, side: sideOf(r), line: numOf(r), risk })
|
|
30
|
+
const first = ch.find((r) => r.k === 'add') || ch[0] // what the file reads now, when there's any
|
|
31
|
+
if (MIGRATION.test(String(file || ''))) return at(first, 'Migration')
|
|
32
|
+
// removed logic: a run of 3+ removed code lines with (almost) nothing added in its place
|
|
33
|
+
for (let i = 0; i < rows.length; i++) {
|
|
34
|
+
if (rows[i].k !== 'del') continue
|
|
35
|
+
let j = i, dels = 0, adds = 0
|
|
36
|
+
while (j < rows.length && (rows[j].k === 'del' || rows[j].k === 'add')) { if (rows[j].k === 'del' && rows[j].t.trim() && !COMMENT.test(rows[j].t)) dels++; if (rows[j].k === 'add' && rows[j].t.trim()) adds++; j++ }
|
|
37
|
+
if (dels >= 3 && adds * 2 < dels) return at(rows[i], 'Removed logic')
|
|
38
|
+
i = j - 1
|
|
39
|
+
}
|
|
40
|
+
const code = ch.filter((r) => r.t.trim() && !COMMENT.test(r.t))
|
|
41
|
+
const a = code.find((r) => AUTH.test(r.t))
|
|
42
|
+
if (a) return at(a, 'Auth / permissions')
|
|
43
|
+
const e = code.find((r) => ERR.test(r.t))
|
|
44
|
+
if (e) return at(e, 'Error handling')
|
|
45
|
+
if (CONFIG.test(String(file || ''))) return at(first, 'Config')
|
|
46
|
+
return at(first, 'Core change', false)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// data (a walkthrough) + its steps in reading order -> the spots, in order:
|
|
50
|
+
// [{ step, snip, hunk, file, side, line, why, kind }] (kind: callout | model | risk | core | sup)
|
|
51
|
+
export function spotsFor(data, order) {
|
|
52
|
+
const steps = order || (data && data.steps) || []
|
|
53
|
+
const model = data && Array.isArray(data.spots) ? data.spots : []
|
|
54
|
+
const main = [], sup = []
|
|
55
|
+
for (const st of steps) {
|
|
56
|
+
const core = st.tag === 'Core'
|
|
57
|
+
const snips = st.snippets || []
|
|
58
|
+
snips.forEach((sn, i) => {
|
|
59
|
+
const here = []
|
|
60
|
+
const lineIn = (side, line) => parsePatch(sn.patch).rows.some((r) => isLine(r) && (side === 'old' ? r.o : r.n) === line)
|
|
61
|
+
for (const c of st.callouts || []) if (c.hunk === sn.hunk && lineIn(c.side, c.line)) here.push({ side: c.side, line: c.line, why: SPOT_WORDS[c.kind] || SPOT_WORDS.look, kind: 'callout' })
|
|
62
|
+
for (const m of model) if (m.step === st.id && m.hunk === sn.hunk && lineIn(m.side, m.line) && !here.some((x) => x.side === m.side && x.line === m.line)) here.push({ side: m.side, line: m.line, why: m.why || 'Worth a look', kind: 'model' })
|
|
63
|
+
if (!here.length && !model.length) {
|
|
64
|
+
const r = snippetRisk(sn.patch, sn.file)
|
|
65
|
+
if (r && (r.risk || core)) here.push({ side: r.side, line: r.line, why: r.why, kind: r.risk ? 'risk' : 'core' })
|
|
66
|
+
else if (r) sup.push({ step: st.id, snip: i, hunk: sn.hunk, file: sn.file, side: r.side, line: r.line, why: 'Supporting', kind: 'sup' })
|
|
67
|
+
} else if (!here.length && core) {
|
|
68
|
+
// the model named spots, just none here: a Core snippet still gets one
|
|
69
|
+
const r = snippetRisk(sn.patch, sn.file)
|
|
70
|
+
if (r) here.push({ side: r.side, line: r.line, why: r.risk ? r.why : 'Core change', kind: r.risk ? 'risk' : 'core' })
|
|
71
|
+
}
|
|
72
|
+
here.sort((x, y) => x.line - y.line)
|
|
73
|
+
for (const x of here) main.push({ step: st.id, snip: i, hunk: sn.hunk, file: sn.file, ...x })
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return main.length ? main : sup
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The spot after (dir 1) or before (-1) index cur, wrapping; -1 when there are none.
|
|
80
|
+
export function nextSpotIndex(n, cur, dir = 1) {
|
|
81
|
+
if (!n) return -1
|
|
82
|
+
if (cur == null || cur < 0) return dir > 0 ? 0 : n - 1
|
|
83
|
+
return ((cur + dir) % n + n) % n
|
|
84
|
+
}
|
|
85
|
+
// "2 of 7 spots" once you've started, "7 spots" before
|
|
86
|
+
export function spotCountWords(n, cur) {
|
|
87
|
+
if (!n) return 'No spots'
|
|
88
|
+
return (cur != null && cur >= 0 ? (cur + 1) + ' of ' + n : String(n)) + (n === 1 ? ' spot' : ' spots')
|
|
89
|
+
}
|