@leftoff/cli 0.9.18-beta.3 → 0.9.18-beta.4
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 +3 -0
- package/bin/leftoff.js +488 -487
- package/bin/pty-host-main.js +10 -10
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +23 -1
- package/package.json +1 -1
- package/ui/app.css +18 -0
- package/ui/app.js +15 -6
- package/ui/semdiff.js +77 -12
package/bin/pty-host-main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import y from"node:fs";import it from"node:net";import W from"node:path";import
|
|
2
|
+
import y from"node:fs";import it from"node:net";import W from"node:path";import C from"node:fs";import P from"node:path";import{spawn as Z,execFile as ut}from"node:child_process";import{createRequire as q}from"node:module";import{StringDecoder as Q}from"node:string_decoder";var wt=2*1024*1024;var gt=192*1024;var yt=64*1024;var tt=String.raw`
|
|
3
3
|
import os, sys, pty, select, struct, fcntl, termios, signal
|
|
4
4
|
cols, rows = int(sys.argv[1]), int(sys.argv[2])
|
|
5
5
|
argv = sys.argv[3:]
|
|
@@ -51,16 +51,16 @@ while True:
|
|
|
51
51
|
st = os.waitpid(pid, 0)[1]
|
|
52
52
|
code = os.waitstatus_to_exitcode(st)
|
|
53
53
|
sys.exit(code if code >= 0 else 128 - code)
|
|
54
|
-
`;function et({platform:t=process.platform,exists:n=c=>{try{return
|
|
54
|
+
`;function et({platform:t=process.platform,exists:n=c=>{try{return C.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return C.statSync(c).isDirectory()}catch{return!1}}}={}){return(t==="darwin"?["/opt/homebrew/bin/python3","/usr/local/bin/python3",...o("/Library/Developer/CommandLineTools")||o("/Applications/Xcode.app")?["/usr/bin/python3"]:[]]:["/usr/bin/python3","/usr/local/bin/python3","/bin/python3"]).find(n)||null}function st(t){return{kind:"node-pty",spawn({file:n,args:o,cwd:c,env:a,cols:d,rows:e}){let r=t.spawn(n,o,{name:"xterm-256color",cols:d,rows:e,cwd:c,env:a});return{pid:r.pid,onData:s=>r.onData(s),onExit:s=>r.onExit(({exitCode:p,signal:u})=>s({code:p,signal:u||null})),write:s=>r.write(s),resize:(s,p)=>{try{r.resize(s,p)}catch{}},kill:(s="SIGHUP")=>{try{r.kill(s)}catch{}}}}}}function U(t,{spawnImpl:n=Z}={}){return{kind:"python",spawn({file:o,args:c,cwd:a,env:d,cols:e,rows:r}){let s=n(t,["-c",tt,String(e),String(r),o,...c],{cwd:a,env:d,stdio:["pipe","pipe","pipe","pipe"]}),p=new Q("utf8"),u=[],H=[],j=!1;s.stdout.on("data",i=>{let h=p.write(i);if(h)for(let b of u)b(h)}),s.stderr.on("data",i=>{let h=String(i);for(let b of u)b(h)});let A=s.stdio[3];for(let i of[s.stdin,A])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let F of u)F(b);for(let F of H)F({code:i??null,signal:h||null})};s.on("exit",(i,h)=>setTimeout(()=>G(i,h),20)),s.on("error",i=>{for(let h of u)h(`\r
|
|
55
55
|
[terminal] `+i.message+`\r
|
|
56
|
-
`);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{
|
|
57
|
-
`)}catch{}},kill:(i="SIGHUP")=>{try{
|
|
58
|
-
`)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>
|
|
56
|
+
`);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{A.write("R "+i+" "+h+`
|
|
57
|
+
`)}catch{}},kill:(i="SIGHUP")=>{try{A.write("K "+(V[i]||1)+`
|
|
58
|
+
`)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>C.statSync(d),chmod:a=(d,e)=>C.chmodSync(d,e)}={}){let d=[];for(let e of[P.join(t,"prebuilds",n+"-"+o,"spawn-helper"),P.join(t,"build","Release","spawn-helper")]){let r;try{r=c(e)}catch{continue}if(!(!r.isFile()||(r.mode&73)===73))try{a(e,r.mode&511|493),d.push(e)}catch{}}return d}function rt(t,n){if(!n)return t;let o=t;return{get kind(){return o.kind},spawn(c){try{return o.spawn(c)}catch(a){if(o!==t)throw a;return o=n(),o.spawn(c)}}}}function K({requireImpl:t=q(import.meta.url),python:n=et(),env:o=process.env}={}){let c=null;if(o.LEFTOFF_TERM_BACKEND!=="python")try{let a=t("node-pty");try{nt(P.dirname(t.resolve("node-pty/package.json")))}catch{}return rt(st(a),n?()=>U(n):null)}catch(a){c=a}return n?U(n):{kind:null,why:"No terminal backend: node-pty isn’t installed"+(c&&!/Cannot find module/.test(c.message)?" ("+String(c.message).split(`
|
|
59
59
|
`)[0]+")":"")+" and there’s no python3. Run `leftoff upgrade`, or install python3."}}var X=process.argv[2],l;try{l=JSON.parse(y.readFileSync(X,"utf8"))}catch(t){process.stderr.write("pty-host: no spec: "+t.message+`
|
|
60
|
-
`),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),
|
|
61
|
-
`);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var
|
|
62
|
-
`;for(let o of
|
|
63
|
-
`))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-
|
|
60
|
+
`),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),N=W.join(B,_+".json"),ot=l.bufferMax||2*1024*1024,E=t=>process.stderr.write(new Date().toISOString()+" "+_+" "+t+`
|
|
61
|
+
`);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var I=K({env:l.env||process.env});I.kind||(E("no backend: "+I.why),process.exit(3));var f;try{f=I.spawn({file:l.file,args:l.args||[],cwd:l.cwd,env:l.env||process.env,cols:l.cols||80,rows:l.rows||24})}catch(t){E("spawn failed: "+t.message),process.exit(4)}var k=[],M=0,x=0,m=!1,O=null,S=null,J=!1,w=l.cols||80,g=l.rows||24,L={v:1,id:_,hostPid:process.pid,pid:f.pid||null,cwd:l.cwd,createdAt:new Date().toISOString(),cols:w,rows:g,backend:I.kind,...l.meta||{}},D=()=>{try{let t=N+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,N)}catch(t){E("meta: "+t.message)}},T=new Set,R=t=>{let n=JSON.stringify(t)+`
|
|
62
|
+
`;for(let o of T)if(o.ready)try{o.write(n)}catch{}},$=()=>[...T].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),M+=t.length,x+=t.length;M>ot&&k.length>1;)M-=k[0].length,k.shift();R({t:"out",d:t,end:x})});var ct=null;f.onExit(({code:t,signal:n})=>{m=!0,O=t??null,S=n||null,E("program exited ("+O+(S?", "+S:"")+")"),D(),R({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of T)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,N])try{y.rmSync(t,{force:!0})}catch{}setTimeout(()=>process.exit(0),50)}try{y.rmSync(v,{force:!0})}catch{}var z=it.createServer(t=>{t.setEncoding("utf8");let n="";T.add(t),t.on("data",c=>{n+=c;let a;for(;(a=n.indexOf(`
|
|
63
|
+
`))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-M,p=typeof e.since=="number"&&e.since>=s&&e.since<=x?e.since:s;t.write(JSON.stringify({t:"hello",id:_,pid:f.pid||null,start:s,end:x,exited:m,code:O,signal:S,cols:w,rows:g,meta:L})+`
|
|
64
64
|
`);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
|
|
65
65
|
`),m&&t.write(JSON.stringify({t:"exit",code:O,signal:S})+`
|
|
66
|
-
`),
|
|
66
|
+
`),R({t:"clients",list:$()})}else if(e.t==="in"){if(!m)try{f.write(String(e.d||""))}catch{}}else if(e.t==="resize"){let r=Math.max(2,Math.min(1e3,Number(e.c)||w)),s=Math.max(1,Math.min(500,Number(e.r)||g));if(r!==w||s!==g){w=r,g=s;try{f.resize(w,g)}catch{}}}else if(e.t==="kill")if(J=!0,m)Y();else{try{f.kill(e.sig||"SIGHUP")}catch{}setTimeout(()=>{if(!m)try{f.kill("SIGKILL")}catch{}},3e3).unref()}else if(e.t==="meta"&&e.patch&&typeof e.patch=="object"){for(let[r,s]of Object.entries(e.patch))/^(title|kind|sessionId|run|startCwd|renamed)$/.test(r)&&(L[r]=s);D()}}});let o=()=>{T.delete(t)&&R({t:"clients",list:$()})};t.on("close",o),t.on("error",o)}),lt=process.umask(127);z.listen(v,()=>{process.umask(lt);try{y.chmodSync(v,384)}catch{}D(),E("listening ("+I.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.4",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/register.js
CHANGED
|
@@ -26,9 +26,10 @@ const PANE_ID = 'checkpoints'
|
|
|
26
26
|
const POST_TIMEOUT_MS = 800
|
|
27
27
|
const END_TIMEOUT_MS = 1000
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
|
+
const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
|
|
29
30
|
|
|
30
31
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
32
|
+
const MOD_VERSION = '0.9.18-beta.4' // keep in step with .claude-plugin/plugin.json
|
|
32
33
|
|
|
33
34
|
const st = {
|
|
34
35
|
internal: false,
|
|
@@ -69,6 +70,8 @@ const st = {
|
|
|
69
70
|
listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
|
|
70
71
|
listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
|
|
71
72
|
paneNeeds: 0, // sessions that need you (the pane's top line)
|
|
73
|
+
turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
|
|
74
|
+
stopAt: 0, // and when a Stop last came (classic.Stop)
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
// Millisecond precision: the daemon dedupes events per (sessionId, type, at).
|
|
@@ -243,6 +246,16 @@ async function heartbeat($) {
|
|
|
243
246
|
}
|
|
244
247
|
}
|
|
245
248
|
|
|
249
|
+
// 2026-11-03 a turn completed and no Stop came for it within STOP_GRACE_MS:
|
|
250
|
+
// report its end (once; nothing when this process doesn't hold a turn open).
|
|
251
|
+
async function stopFallback($, completedAt, reason) {
|
|
252
|
+
if (st.internal || !st.sessionId) return
|
|
253
|
+
if (st.stopAt && st.stopAt >= (st.turnStartAt || 0)) return // its Stop came
|
|
254
|
+
if (!(st.record && st.record.inTurn)) return
|
|
255
|
+
st.stopAt = Date.now()
|
|
256
|
+
await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
|
|
257
|
+
}
|
|
258
|
+
|
|
246
259
|
// The commands, in the order they're tried. Claude Code 2.1.295's built-ins
|
|
247
260
|
// own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
|
|
248
261
|
// ours share a /cp prefix; each command lists fallbacks in case a later
|
|
@@ -1083,6 +1096,7 @@ export function register(on, options) {
|
|
|
1083
1096
|
})
|
|
1084
1097
|
|
|
1085
1098
|
on('turn.start', async ($, e, next) => {
|
|
1099
|
+
st.turnStartAt = Date.now()
|
|
1086
1100
|
await sendOrSpool($, 'turn_started', {})
|
|
1087
1101
|
return next(e)
|
|
1088
1102
|
})
|
|
@@ -1091,10 +1105,18 @@ export function register(on, options) {
|
|
|
1091
1105
|
// classic.Stop reports a normal end with its details; Stop doesn't fire for
|
|
1092
1106
|
// an interrupted or failed turn, so those are reported here.
|
|
1093
1107
|
if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
|
|
1108
|
+
// 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
|
|
1109
|
+
// (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
|
|
1110
|
+
// and a tool can end a turn), and then this turn stayed open on the board
|
|
1111
|
+
// for good. If no Stop came for this turn shortly after it completed, the
|
|
1112
|
+
// end is reported from here; a Stop that comes later is the same turn
|
|
1113
|
+
// (the daemon doesn't count it twice).
|
|
1114
|
+
else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }) }
|
|
1094
1115
|
return next(e)
|
|
1095
1116
|
})
|
|
1096
1117
|
|
|
1097
1118
|
on('classic.Stop', async ($, e, next) => {
|
|
1119
|
+
st.stopAt = Date.now()
|
|
1098
1120
|
// v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
|
|
1099
1121
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
1100
1122
|
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.4",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -5274,6 +5274,18 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5274
5274
|
.wt-sf .wt-snip + .wt-snip { border-top: 1px dashed var(--line); }
|
|
5275
5275
|
.wt-sf .wt-snip-h { margin: 0; padding: 4px 10px; }
|
|
5276
5276
|
@media (pointer: fine) { .wt-sf > summary { min-height: 34px; } .wt-sfa-b { min-height: 32px; } }
|
|
5277
|
+
/* 2026-11-03 a file a step shares with an earlier one: folded, with a quiet way back to where it's open */
|
|
5278
|
+
.wt-also { display: inline-flex; align-items: center; gap: 4px; align-self: stretch; padding: 0 6px; font-size: 12px; color: var(--text2); text-decoration: none; white-space: nowrap; }
|
|
5279
|
+
.wt-also .ic { width: 13px; height: 13px; color: var(--muted); }
|
|
5280
|
+
.wt-also:hover { color: var(--text); text-decoration: underline; text-underline-offset: 2px; }
|
|
5281
|
+
.wt-also:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
|
|
5282
|
+
.wt-sf-also > summary .wt-sf-p { color: var(--text2); }
|
|
5283
|
+
/* the lines the step's refs name, when it shows a hunk whole: the rest steps back */
|
|
5284
|
+
.wt-gd-focus .gd-row:not(.wt-focus), .wt-gd-focus .gd-half:not(.wt-focus):not(.gd-empty) { opacity: .55; }
|
|
5285
|
+
.wt-gd-focus .gd-row.wt-focus .gd-n, .wt-gd-focus .gd-half.wt-focus .gd-gut { box-shadow: inset 2px 0 0 var(--muted); }
|
|
5286
|
+
.wt-focus-w { display: inline-flex; align-items: center; gap: 6px; }
|
|
5287
|
+
.wt-focus-g { width: 2px; height: 12px; border-radius: 1px; background: var(--muted); }
|
|
5288
|
+
.wt-snip-none { margin: 0; padding: 10px; font-size: 12.5px; }
|
|
5277
5289
|
/* "changed since": a half-filled circle and the words (the stale bar uses the same glyph) */
|
|
5278
5290
|
.wt-chg { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 1px 7px 0; box-sizing: border-box; border: 1px dashed var(--line-strong); border-radius: 6px; font-size: 11.5px; font-weight: 600; line-height: 1; color: var(--text2); }
|
|
5279
5291
|
.wt-chg-g { flex: none; width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; background: linear-gradient(90deg, currentColor 50%, transparent 50%); box-sizing: border-box; }
|
|
@@ -5378,3 +5390,9 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5378
5390
|
.wid-path .wt-nots, .wid-path .wt-pre { font-family: var(--sans, inherit); }
|
|
5379
5391
|
.wid-scope-n { display: flex; gap: 6px; align-items: flex-start; }
|
|
5380
5392
|
.wid-scope-n .ic { width: 13px; height: 13px; flex: none; margin-top: 2px; }
|
|
5393
|
+
|
|
5394
|
+
/* 2026-11-03 "hooks not reporting": a quiet note beside the session's pills (a dashed ring plus words) */
|
|
5395
|
+
.sub .s-hooks { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
5396
|
+
.sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
|
|
5397
|
+
.fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
|
|
5398
|
+
.fs-side.is-scrolled .fs-head .sub > .s-hooks { display: none; }
|
package/ui/app.js
CHANGED
|
@@ -54,7 +54,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
|
|
|
54
54
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
55
55
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
56
56
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
57
|
-
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
57
|
+
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
58
58
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
59
59
|
import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
60
60
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
@@ -468,6 +468,10 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
|
|
|
468
468
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
469
469
|
// 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
|
|
470
470
|
const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
|
|
471
|
+
// 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
|
|
472
|
+
// are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
|
|
473
|
+
const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
|
|
474
|
+
const hooksQuietHtml = (s) => (s && s.hooksQuiet && !s.endedAt ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : '')
|
|
471
475
|
const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
|
|
472
476
|
|
|
473
477
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
@@ -1142,6 +1146,7 @@ async function wtCheck(sid) {
|
|
|
1142
1146
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
|
|
1143
1147
|
const had = wtUi.data.get(k)
|
|
1144
1148
|
if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
|
|
1149
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1145
1150
|
wtUi.data.set(k, r)
|
|
1146
1151
|
if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
|
|
1147
1152
|
}
|
|
@@ -1215,6 +1220,7 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
|
|
|
1215
1220
|
wtFill(wtSession(sid))
|
|
1216
1221
|
try {
|
|
1217
1222
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
|
|
1223
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1218
1224
|
wtUi.data.set(k, r)
|
|
1219
1225
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1220
1226
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -1339,18 +1345,21 @@ function wtRevNext() {
|
|
|
1339
1345
|
if (!done) wtReviewed(sid, step, { force: true, advance: false })
|
|
1340
1346
|
wtSlideGo(st.to)
|
|
1341
1347
|
}
|
|
1342
|
-
function wtGo(sid, step, { group = null } = {}) {
|
|
1348
|
+
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
1343
1349
|
const node = wtUi.node; if (!node) return
|
|
1344
1350
|
if (group && node.querySelector('#wtDeck')) { const d = node.querySelector('#wtDeck'), sk = d.lastElementChild; if (sk && !sk.dataset.step && sk.dataset.slide !== '0') wtSlideGo(Number(sk.dataset.slide)) }
|
|
1345
1351
|
if (group) { const g = node.querySelector('#wt-g-' + CSS.escape(group)); if (g) { g.open = true; g.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }) } return }
|
|
1346
1352
|
const k = wtKey(sid)
|
|
1347
1353
|
const deck = node.querySelector('#wtDeck')
|
|
1348
|
-
|
|
1354
|
+
// 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
|
|
1355
|
+
const fileRow = () => { if (!file) return null; const d = node.querySelector('details.wt-sf[data-sfk="' + CSS.escape(step + '|' + file) + '"]'); if (d && !d.open) d.open = true; return d }
|
|
1356
|
+
if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl); const d = fileRow(); if (d) d.scrollIntoView({ block: 'nearest', behavior: 'auto' }) } return } // 0.9.18 the slideshow: that step's slide
|
|
1349
1357
|
wtUi.cur.set(k, step)
|
|
1350
1358
|
for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1351
1359
|
for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1352
1360
|
const el = node.querySelector('#wt-' + CSS.escape(step))
|
|
1353
|
-
|
|
1361
|
+
const row = fileRow()
|
|
1362
|
+
if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
|
|
1354
1363
|
const data = wtUi.data.get(k); const n = node.querySelector('#wtPnavN'); if (n && data) n.textContent = stepPos(data.steps, { filter: wtUi.filter, cur: step })
|
|
1355
1364
|
}
|
|
1356
1365
|
// 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
|
|
@@ -2880,7 +2889,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2880
2889
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2881
2890
|
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2882
2891
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2883
|
-
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2892
|
+
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + hooksQuietHtml(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2884
2893
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2885
2894
|
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2886
2895
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
@@ -8543,7 +8552,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8543
8552
|
const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
|
|
8544
8553
|
return
|
|
8545
8554
|
}
|
|
8546
|
-
if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
|
|
8555
|
+
if (action === 'wt-go') { ev.preventDefault(); ev.stopPropagation(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null, file: btn.dataset.file || null }) }
|
|
8547
8556
|
if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
|
|
8548
8557
|
// 2026-10-30 walkthrough round 3
|
|
8549
8558
|
if (action === 'wt-dmode') { try { if (btn.dataset.mode === 'split') localStorage.setItem('lo-wt-split', '1'); else localStorage.removeItem('lo-wt-split') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
|
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
|