opencode-session-turns 0.1.6 → 0.1.7
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/README.md +8 -5
- package/dist/gui-experimental.cjs +3 -3
- package/package.json +1 -1
- package/src/gui.tsx +79 -39
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ click a turn to jump straight to that message.
|
|
|
22
22
|
- 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
|
|
23
23
|
- 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
|
|
24
24
|
- 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
|
|
25
|
-
-
|
|
25
|
+
- 深历史需要逐页加载时,头部实时显示「已加载 N 页 / 耗时 Xs」,目标轮次琥珀色脉冲,
|
|
26
26
|
底部状态条同步提示;按 `Esc` 或点 × 可随时取消。
|
|
27
27
|
- 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角 + 柔光),不再整行填色。
|
|
28
28
|
- 自动高亮当前视口所在的轮次。
|
|
@@ -63,10 +63,13 @@ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
|
|
|
63
63
|
上注册的链接拦截器(Solid Router 的 `create` 钩子)会把它交给**内存 router 的
|
|
64
64
|
navigate()**,从而驱动宿主自己的游标状态机加载历史并定位到该行——全程不碰
|
|
65
65
|
`window.location`。
|
|
66
|
-
3. 宿主每次 seek 只向前加载**约一页(~5
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史。宿主在 `hash.changed` 后会进入
|
|
67
|
+
`pending(目标)` 状态并**异步**加载缺失的历史(内部最多重试 12 次)。因此插件
|
|
68
|
+
**只设置一次 hash 然后耐心等待**:任何额外的 hash 切换都会把宿主的 `pending`
|
|
69
|
+
状态重置、打断正在飞行的历史请求,表现为"每次点击只前进一页"。
|
|
70
|
+
只有当宿主连续 5 秒(首屏 9 秒)毫无变化、判定这一轮已经结束时,插件才把
|
|
71
|
+
hash 弹到一个已挂载的轮次再切回来,开启新的一轮;面板头部实时显示
|
|
72
|
+
「已加载 N 页 / 耗时 Xs」,总预算 45 秒,`Esc` 可取消。
|
|
70
73
|
|
|
71
74
|
已挂载的行则直接用本地滚动,瞬时定位。
|
|
72
75
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const a=require("solid-js/web"),
|
|
2
|
-
`).map(
|
|
1
|
+
"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const a=require("solid-js/web"),R=require("@opencode/plugin/gui"),i=require("solid-js");var Ot=a.template("<div class=sr-status>"),Nt=a.template('<span class="sr-title sr-progress">跳转中'),Ft=a.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),Ht=a.template("<span class=sr-title>会话轮次"),Ut=a.template("<div class=sr-note>"),zt=a.template("<button type=button class=sr-turn><span class=sr-text><span class=sr-num>#</span><span class=sr-time></span></span><span class=sr-dash>");const tt="v13",nt="message-",Et="session-turns:diag",st="session-turns:open",Gt=40,Wt=200,Vt=50,Kt=30,j=45e3,Yt=12e3,Zt=5e3,Xt=9e3,Qt=220,te=800;let At=0,ot="",at="";const _t=o=>{const r=String(o?.text??"").split(`
|
|
2
|
+
`).map(g=>g.trim()).find(g=>g.length>0);if(r)return r;const d=o?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},Ct=o=>String(o).padStart(2,"0"),ee=o=>{const r=new Date(o??0);return`${Ct(r.getHours())}:${Ct(r.getMinutes())}`},Tt=o=>new Promise(r=>setTimeout(r,o)),it=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let r=document.querySelector("[data-message-id]")?.parentElement;for(;r;){const d=getComputedStyle(r);if(/(auto|scroll)/.test(d.overflowY)&&r.scrollHeight>r.clientHeight+1)return r;r=r.parentElement}})(),T=o=>document.getElementById(`${nt}${o}`)??[...document.querySelectorAll("[data-message-id]")].find(r=>r.getAttribute("data-message-id")===o),et=o=>{const r=it(),d=[...document.querySelectorAll("[data-message-id]")],g=d[0]?.getAttribute("data-message-id")??"",S=at?` · 目标 ${T(at)?"在":"不在"} DOM`:"",A=g?` · 行ID ${g.slice(0,20)}`:"";let y="(空)";try{y=o.router.path()||"(空)"}catch{}return`轮次 ${At} · 容器 ${r?"OK":"缺失"} · DOM ${d.length} 行${S}${A} · 路由 ${y} · hash ${window.location.hash||"(空)"}${ot?` · ${ot}`:""}`},rt=(o,r)=>{const d=o.querySelector("[data-session-title]"),g=o.getBoundingClientRect(),S=d instanceof HTMLElement?d.getBoundingClientRect().bottom:g.top,y=r.getBoundingClientRect().top-(S+24);Math.abs(y)>2&&(o.scrollTop+=y)},re=o=>{setTimeout(()=>{if(!o.isConnected)return;const r=it();if(!r)return;const d=r.querySelector("[data-session-title]"),g=d instanceof HTMLElement?d.getBoundingClientRect().bottom:r.getBoundingClientRect().top,S=o.getBoundingClientRect().top;S<g+4&&(r.scrollTop+=S-(g+24))},300)},ne=o=>{let r="";try{r=o.router.path()||""}catch{}return r.startsWith("/")?r:""},se=o=>{const r=document.createElement("a");r.setAttribute("href",o),r.setAttribute("replace",""),r.setAttribute("noscroll",""),r.style.display="none",document.body.appendChild(r);try{r.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{r.remove()}},oe=`
|
|
3
3
|
.sr-rail {
|
|
4
4
|
position: fixed;
|
|
5
5
|
top: 50%;
|
|
@@ -215,4 +215,4 @@
|
|
|
215
215
|
white-space: pre-wrap;
|
|
216
216
|
max-width: min(720px, 90vw);
|
|
217
217
|
}
|
|
218
|
-
`;function
|
|
218
|
+
`;function ae(o){const r=R.usePlugin(),[d,g]=i.createSignal([]),[S,A]=i.createSignal(""),[y,C]=i.createSignal(!1),[Lt,Mt]=i.createSignal(""),[lt,ct]=i.createSignal(""),[k,q]=i.createSignal(!1),[B,L]=i.createSignal(0),[z,M]=i.createSignal(0),[le,J]=i.createSignal(0),[G,O]=i.createSignal(""),$=new Map;let W=0,N=!1,F=!1,V="",I;const E=()=>o.session()?.id,K=()=>{const t=[...$.values()].sort((n,l)=>n.created-l.created),e=i.untrack(d);e.length===t.length&&e.every((n,l)=>n===t[l])||(g(t),At=t.length)},dt=t=>{const e=$.get(t.id);e&&e.created===t.time&&e.text===t.text||$.set(t.id,{id:t.id,created:t.time,text:t.text})},It=async(t,e,n)=>{const l=n?{sessionID:t,type:"user",limit:e,cursor:n}:{sessionID:t,type:"user",limit:e,order:"desc"};return await r.client.message.list(l)},Y=async(t,e)=>{const n=++W;N=!0;let l="";try{let s;const c=e==="full"?Gt:1,u=e==="full"?Wt:Vt;let m=e!=="full";for(let v=0;v<c;v++){let h;try{h=await It(t,u,s)}catch(p){l=String(p?.message??p);break}if(n!==W)return;m||($.clear(),m=!0);let f=0;for(const p of h?.data??[])$.has(p.id)||(dt({id:p.id,time:p.time?.created??0,text:_t(p)}),f++);K();const x=h?.cursor?.next;if(!x||f===0)break;s=x}}finally{N=!1,V=t,ot=l,Mt($.size===0?l||"暂无用户消息":""),F&&(F=!1,ut())}},ut=()=>{I||(I=setTimeout(()=>{I=void 0;const t=i.untrack(E);t&&Y(t,"light")},300))},pt=t=>{if(!(!t||t!==i.untrack(E))){if(N){F=!0;return}ut()}},Dt=t=>{const e=t?.type;if(e==="message.updated"){const n=t?.data??{},l=n.info;l?.role==="user"&&l?.id&&(dt({id:l.id,time:l.time?.created??0,text:_t(l)}),K()),pt(n.sessionID)}else if(e==="message.removed"){const n=t?.data??{};n.messageID&&$.delete(n.messageID)&&K(),pt(n.sessionID)}else if(e==="location.shutdown"){const n=i.untrack(E);n&&V===n&&Y(n,"full")}};i.onMount(()=>{let t=!1,e=0,n;(async()=>{for(;!t;){const c=r.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!t;){const u=await c.next();if(u.done)break;e=0,Dt(u.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await Tt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(n),I&&clearTimeout(I)})}),i.createEffect(()=>{const t=E();t&&i.untrack(()=>{V!==t&&(W++,N=!1,F=!1,$.clear(),g([]),A(""),X="",Y(t,"full"))})});let H;const D=()=>(H?.isConnected||(H=it()),H),Pt=()=>{const t=D();if(!t)return;const e=t.querySelectorAll("[data-message-id]");if(e.length===0)return;const n=t.getBoundingClientRect().top+40;for(const l of e)if(l.getBoundingClientRect().bottom>=n){const s=l.getAttribute("data-message-id");s&&A(s);break}};let Z=!1;const ft=()=>{Z||(Z=!0,requestAnimationFrame(()=>{Z=!1,Pt()}))};i.onMount(()=>{const t=()=>ft();document.addEventListener("scroll",t,!0);const e=setInterval(ft,1500);i.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let gt=0;const w=(t,e=2600)=>{const n=++gt;ct(t),setTimeout(()=>{n===gt&&ct("")},e)};i.onMount(()=>{const t=n=>{if(n.key==="Escape"){if(i.untrack(k)){vt(),n.stopPropagation();return}i.untrack(y)&&(C(!1),n.stopPropagation())}},e=n=>{if(!i.untrack(y))return;(n.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||C(!1)};window.addEventListener("keydown",t,!0),document.addEventListener("pointerdown",e,!0),i.onCleanup(()=>{window.removeEventListener("keydown",t,!0),document.removeEventListener("pointerdown",e,!0)})}),i.onMount(()=>{const t=()=>w(`${tt} · ${et(r)}`),e=()=>C(n=>!n);window.addEventListener(Et,t),window.addEventListener(st,e),i.onCleanup(()=>{window.removeEventListener(Et,t),window.removeEventListener(st,e)}),w(`会话轮次 ${tt} 已加载`)});let X="",_=0,ht=0,mt="";const Q=t=>{try{const e=t.querySelector('[data-slot="user-message-text"]')??t.querySelector('[data-slot="user-message-body"]')??t;e.classList.remove("sr-flash-ring"),e.offsetWidth,e.classList.add("sr-flash-ring"),setTimeout(()=>e.classList.remove("sr-flash-ring"),1900)}catch{}},jt=()=>{for(const t of d())if(T(t.id))return t},bt=(t,e)=>{se(`${t}#${nt}${e}`),X=e},wt=()=>{const e=D()?.querySelectorAll("[data-message-id]")??[];if(e.length===0)return"0";const n=e[0].getAttribute("data-message-id")??"",l=e[e.length-1].getAttribute("data-message-id")??"";return`${e.length}:${n}:${l}`},Rt=(t,e,n,l)=>new Promise(s=>{let c=!1,u,m,v;const h=Date.now();let x=wt(),p=h,b=0;const P=U=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(m),clearTimeout(v),s(U??null)}},xt=U=>{if(e!==_)return P(null);const kt=U?T(t):document.getElementById(`${nt}${t}`);if(kt)return P(kt);if(!U)return;const St=wt(),$t=Date.now();if(St!==x){x=St,p=$t,b++,l?.(b);return}const Jt=b>0?Zt:Xt;$t-p>Jt&&P(null)},yt=T(t);if(yt)return P(yt);u=new MutationObserver(()=>xt(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),m=setInterval(()=>xt(!0),120),v=setTimeout(()=>P(T(t)),Math.max(400,n))}),vt=()=>{_++,q(!1),L(0),M(0),J(0),O(""),w("已取消跳转")},qt=async t=>{if(k()&&G()===t.id){w(`正在跳转 · 已加载 ${B()} 页 · ${z()}s`,j);return}const e=++_;q(!1),L(0),M(0),J(0),O(""),at=t.id;const n=Date.now();if(t.id===mt&&n-ht<te){const h=T(t.id),f=D();if(h&&f){rt(f,h),Q(h);return}}ht=n,mt=t.id;const l=D(),s=T(t.id);if(s&&l){rt(l,s),Q(s),w("已定位");return}const c=ne(r);if(!c){w(`无法读取当前路由,无法定位 · ${et(r)}`);return}const u=E();q(!0),O(t.id),L(0),M(0),C(!0),w("正在定位…",j);const m=Date.now(),v=setInterval(()=>{M(Math.round((Date.now()-m)/1e3))},500);try{const h=Date.now()+j;for(let f=0;f<Kt&&Date.now()<h;f++){if(e!==_||E()!==u)return;if(J(f+1),X===t.id){const b=jt()??d()[0];b&&(bt(c,b.id),await Tt(Qt))}if(e!==_||E()!==u)return;bt(c,t.id),w(`跳转中 · 已加载 ${B()} 页`,j);const x=Math.min(Yt,h-Date.now()),p=await Rt(t.id,e,x,b=>{L(b),w(`跳转中 · 已加载 ${b} 页`,j)});if(e!==_||E()!==u)return;if(p){const b=D()??l;b&&rt(b,p),Q(p),w("已定位"),re(p);return}}e===_&&w(`未能定位 · ${et(r)}`)}finally{clearInterval(v),e===_&&(q(!1),L(0),M(0),J(0),O(""))}};i.createEffect(()=>{k()&&G()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Bt=[a.createComponent(i.Show,{get when(){return lt()},get children(){var t=Ot();return a.insert(t,lt),t}}),(()=>{var t=Ft(),e=t.firstChild,n=e.firstChild,l=e.nextSibling;return t.addEventListener("mouseleave",()=>{k()||C(!1)}),t.addEventListener("mouseenter",()=>C(!0)),a.insert(e,a.createComponent(i.Show,{get when(){return k()},get fallback(){return(()=>{var s=Ht();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),s})()},get children(){var s=Nt();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>B()>0);return()=>c()?` · 已加载 ${B()} 页`:""})(),null),a.insert(s,(()=>{var c=a.memo(()=>z()>0);return()=>c()?` · ${z()}s`:""})(),null),s}}),n),n.$$click=()=>{k()?vt():C(!1)},a.insert(l,a.createComponent(i.Show,{get when(){return d().length>0},get fallback(){return(()=>{var s=Ut();return a.insert(s,()=>Lt()||`… ${tt}`),s})()},get children(){return a.createComponent(i.For,{get each(){return d()},children:(s,c)=>(()=>{var u=zt(),m=u.firstChild,v=m.firstChild;v.firstChild;var h=v.nextSibling;return u.$$click=()=>{qt(s)},a.insert(v,()=>c()+1,null),a.insert(h,()=>ee(s.created)),a.insert(m,()=>s.text,null),a.effect(f=>{var x=S()===s.id?"true":"false",p=k()&&G()===s.id?"true":"false";return x!==f.e&&a.setAttribute(u,"data-active",f.e=x),p!==f.t&&a.setAttribute(u,"data-busy",f.t=p),f},{e:void 0,t:void 0}),u})()})}})),a.effect(s=>{var c=y()?"true":"false",u=k()?"true":"false",m=k()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==s.e&&a.setAttribute(t,"data-open",s.e=c),u!==s.t&&a.setAttribute(t,"data-jumping",s.t=u),m!==s.a&&a.setAttribute(n,"title",s.a=m),s},{e:void 0,t:void 0,a:void 0}),t})()];return a.createComponent(a.Portal,{get mount(){return document.body},children:Bt})}const ie=R.Plugin.define({i18n:{en:{"command.jump":"Session turns: show / jump","command.jump.description":"Reveal the turn rail; click a turn to jump to that message"}},setup(o){o.add(R.Style,oe),o.add(R.Slot,{at:"window.bottom",render:()=>a.createComponent(i.Show,{get when(){return o.sessions.current()},get children(){return a.createComponent(ae,{session:()=>o.sessions.current()})}})}),o.add(R.Command,()=>({id:"jump",title:o.t("command.jump"),description:o.t("command.jump.description"),group:o.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(st))}}))}});a.delegateEvents(["click"]);exports.default=ie;
|
package/package.json
CHANGED
package/src/gui.tsx
CHANGED
|
@@ -17,13 +17,18 @@ const FULL_PAGE_LIMIT = 40
|
|
|
17
17
|
const FULL_PAGE_SIZE = 200
|
|
18
18
|
const LIGHT_PAGE_SIZE = 50
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
20
|
+
// Jump budgets.
|
|
21
|
+
//
|
|
22
|
+
// The host loads missing history asynchronously and retries a missing seek up
|
|
23
|
+
// to 12 times on its own. The plugin therefore does NOT poll the hash: it
|
|
24
|
+
// sets it once and waits. JUMP_CHAIN_MS is how long a single chain may run,
|
|
25
|
+
// JUMP_IDLE_MS how long the host may stay completely idle before the plugin
|
|
26
|
+
// assumes the chain died and starts a new one.
|
|
27
|
+
const JUMP_ROUNDS = 30
|
|
24
28
|
const JUMP_TOTAL_MS = 45000
|
|
25
|
-
const
|
|
26
|
-
const
|
|
29
|
+
const JUMP_CHAIN_MS = 12000
|
|
30
|
+
const JUMP_IDLE_MS = 5000
|
|
31
|
+
const JUMP_FIRST_IDLE_MS = 9000
|
|
27
32
|
const JUMP_TOGGLE_DWELL_MS = 220
|
|
28
33
|
const SAME_TARGET_WINDOW_MS = 800
|
|
29
34
|
|
|
@@ -393,9 +398,11 @@ function Rail(props) {
|
|
|
393
398
|
const [note, setNote] = createSignal("")
|
|
394
399
|
const [status, setStatus] = createSignal("")
|
|
395
400
|
// Jump progress. While `jumping` is true the panel stays pinned open and the
|
|
396
|
-
// header reports the
|
|
401
|
+
// header reports what the host is doing; `busyID` marks the target row.
|
|
397
402
|
const [jumping, setJumping] = createSignal(false)
|
|
398
|
-
const [
|
|
403
|
+
const [jumpPages, setJumpPages] = createSignal(0)
|
|
404
|
+
const [jumpElapsed, setJumpElapsed] = createSignal(0)
|
|
405
|
+
const [jumpChain, setJumpChain] = createSignal(0)
|
|
399
406
|
const [busyID, setBusyID] = createSignal("")
|
|
400
407
|
|
|
401
408
|
// ---- turn cache ---------------------------------------------------------
|
|
@@ -744,10 +751,14 @@ function Rail(props) {
|
|
|
744
751
|
return `${rows.length}:${first}:${last}`
|
|
745
752
|
}
|
|
746
753
|
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// the
|
|
750
|
-
|
|
754
|
+
// Waits until the target row is mounted.
|
|
755
|
+
//
|
|
756
|
+
// Resolves with the row on success, or null when the host stopped making
|
|
757
|
+
// progress (its own seek chain ran out) or the timeout hit. Crucially it
|
|
758
|
+
// does NOT resolve just because rows appeared: the host may still be
|
|
759
|
+
// fetching the rest of the history for this target, and interrupting it
|
|
760
|
+
// (by toggling the hash) is what made deep jumps crawl one page per click.
|
|
761
|
+
const waitForRow = (messageID, token, timeout, onPage) =>
|
|
751
762
|
new Promise((resolve) => {
|
|
752
763
|
let done = false
|
|
753
764
|
let observer
|
|
@@ -756,7 +767,8 @@ function Rail(props) {
|
|
|
756
767
|
const started = Date.now()
|
|
757
768
|
const baseline = rowSignature()
|
|
758
769
|
let lastSig = baseline
|
|
759
|
-
let
|
|
770
|
+
let lastChange = started
|
|
771
|
+
let pages = 0
|
|
760
772
|
const finish = (row) => {
|
|
761
773
|
if (done) return
|
|
762
774
|
done = true
|
|
@@ -778,15 +790,17 @@ function Rail(props) {
|
|
|
778
790
|
const now = Date.now()
|
|
779
791
|
if (sig !== lastSig) {
|
|
780
792
|
lastSig = sig
|
|
781
|
-
|
|
793
|
+
lastChange = now
|
|
794
|
+
pages++
|
|
795
|
+
onPage?.(pages)
|
|
782
796
|
return
|
|
783
797
|
}
|
|
784
|
-
//
|
|
785
|
-
// for
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
798
|
+
// Nothing has changed for a while: the host has no more history in
|
|
799
|
+
// flight for this target. Before the first page lands we are even more
|
|
800
|
+
// patient - the initial fetch can take seconds, and bouncing the hash
|
|
801
|
+
// then would abort it.
|
|
802
|
+
const idleLimit = pages > 0 ? JUMP_IDLE_MS : JUMP_FIRST_IDLE_MS
|
|
803
|
+
if (now - lastChange > idleLimit) finish(null)
|
|
790
804
|
}
|
|
791
805
|
const existing = findRow(messageID)
|
|
792
806
|
if (existing) return finish(existing)
|
|
@@ -799,7 +813,9 @@ function Rail(props) {
|
|
|
799
813
|
const cancelJump = () => {
|
|
800
814
|
jumpToken++
|
|
801
815
|
setJumping(false)
|
|
802
|
-
|
|
816
|
+
setJumpPages(0)
|
|
817
|
+
setJumpElapsed(0)
|
|
818
|
+
setJumpChain(0)
|
|
803
819
|
setBusyID("")
|
|
804
820
|
flash("已取消跳转")
|
|
805
821
|
}
|
|
@@ -810,7 +826,7 @@ function Rail(props) {
|
|
|
810
826
|
// what makes a deep jump advance only one page per click. Re-affirm the
|
|
811
827
|
// progress instead and let the running loop finish.
|
|
812
828
|
if (jumping() && busyID() === turn.id) {
|
|
813
|
-
flash(`正在跳转 ·
|
|
829
|
+
flash(`正在跳转 · 已加载 ${jumpPages()} 页 · ${jumpElapsed()}s`, JUMP_TOTAL_MS)
|
|
814
830
|
return
|
|
815
831
|
}
|
|
816
832
|
|
|
@@ -819,7 +835,9 @@ function Rail(props) {
|
|
|
819
835
|
// jump's state when the new target takes the mounted fast path, where the
|
|
820
836
|
// superseded loop's finally no longer touches these signals.
|
|
821
837
|
setJumping(false)
|
|
822
|
-
|
|
838
|
+
setJumpPages(0)
|
|
839
|
+
setJumpElapsed(0)
|
|
840
|
+
setJumpChain(0)
|
|
823
841
|
setBusyID("")
|
|
824
842
|
lastJumpID = turn.id
|
|
825
843
|
const now = Date.now()
|
|
@@ -855,43 +873,59 @@ function Rail(props) {
|
|
|
855
873
|
return
|
|
856
874
|
}
|
|
857
875
|
|
|
858
|
-
// Deep history
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
876
|
+
// Deep history.
|
|
877
|
+
//
|
|
878
|
+
// The host's own seek machine does the heavy lifting: hash.changed ->
|
|
879
|
+
// applying -> pending(target) -> target.synced -> seek, and it loads the
|
|
880
|
+
// missing history asynchronously (up to 12 internal retries). Every extra
|
|
881
|
+
// hash change we send RESETS that pending state and aborts the history
|
|
882
|
+
// fetch in flight, which is why hammering the hash made a deep jump
|
|
883
|
+
// advance a single page at a time.
|
|
884
|
+
//
|
|
885
|
+
// So: set the hash once, then be patient. Only when the host has been
|
|
886
|
+
// completely idle for JUMP_IDLE_MS do we bounce the hash through a
|
|
887
|
+
// mounted turn to kick off a fresh chain.
|
|
864
888
|
const sessionAtJump = sessionID()
|
|
865
889
|
setJumping(true)
|
|
866
890
|
setBusyID(turn.id)
|
|
867
|
-
|
|
891
|
+
setJumpPages(0)
|
|
892
|
+
setJumpElapsed(0)
|
|
868
893
|
// Pin the panel open even if the pointer has left it meanwhile.
|
|
869
894
|
setOpen(true)
|
|
870
895
|
flash("正在定位…", JUMP_TOTAL_MS)
|
|
896
|
+
const startedAt = Date.now()
|
|
897
|
+
const elapsedTimer = setInterval(() => {
|
|
898
|
+
setJumpElapsed(Math.round((Date.now() - startedAt) / 1000))
|
|
899
|
+
}, 500)
|
|
871
900
|
try {
|
|
872
901
|
const deadline = Date.now() + JUMP_TOTAL_MS
|
|
873
|
-
for (let
|
|
902
|
+
for (let chain = 0; chain < JUMP_ROUNDS && Date.now() < deadline; chain++) {
|
|
874
903
|
// Abandon the loop if the user switched sessions mid-jump: toggling the
|
|
875
904
|
// old route hash would navigate back to the previous session.
|
|
876
905
|
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
877
|
-
|
|
878
|
-
flash(`跳转中 · 第 ${round + 1} 页`, JUMP_TOTAL_MS)
|
|
906
|
+
setJumpChain(chain + 1)
|
|
879
907
|
|
|
880
908
|
if (lastNavID === turn.id) {
|
|
909
|
+
// The hash already points at the target and the host ignores a
|
|
910
|
+
// repeated hash value, so bounce through a mounted turn to force a
|
|
911
|
+
// fresh seek. This is the fallback path, not the normal one.
|
|
881
912
|
const other = oldestLoadedTurn() ?? turns()[0]
|
|
882
913
|
if (other) {
|
|
883
914
|
navTo(path, other.id)
|
|
884
915
|
// Let the host commit the intermediate hash before seeking back;
|
|
885
|
-
// too fast and both clicks collapse into the same target hash
|
|
886
|
-
// the cursor machine never runs another page.
|
|
916
|
+
// too fast and both clicks collapse into the same target hash.
|
|
887
917
|
await sleep(JUMP_TOGGLE_DWELL_MS)
|
|
888
918
|
}
|
|
889
919
|
}
|
|
890
920
|
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
891
921
|
navTo(path, turn.id)
|
|
922
|
+
flash(`跳转中 · 已加载 ${jumpPages()} 页`, JUMP_TOTAL_MS)
|
|
892
923
|
|
|
893
|
-
const remaining = Math.min(
|
|
894
|
-
const landed = await waitForRow(turn.id, token, remaining)
|
|
924
|
+
const remaining = Math.min(JUMP_CHAIN_MS, deadline - Date.now())
|
|
925
|
+
const landed = await waitForRow(turn.id, token, remaining, (pages) => {
|
|
926
|
+
setJumpPages(pages)
|
|
927
|
+
flash(`跳转中 · 已加载 ${pages} 页`, JUMP_TOTAL_MS)
|
|
928
|
+
})
|
|
895
929
|
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
896
930
|
if (landed) {
|
|
897
931
|
const live = scroller() ?? sc
|
|
@@ -901,14 +935,19 @@ function Rail(props) {
|
|
|
901
935
|
repairScroll(landed)
|
|
902
936
|
return
|
|
903
937
|
}
|
|
938
|
+
// The host went idle without landing: it exhausted this chain. The
|
|
939
|
+
// next iteration bounces the hash to start a new one.
|
|
904
940
|
}
|
|
905
941
|
|
|
906
942
|
if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
|
|
907
943
|
} finally {
|
|
944
|
+
clearInterval(elapsedTimer)
|
|
908
945
|
// A newer jump owns the signals when the token moved on.
|
|
909
946
|
if (token === jumpToken) {
|
|
910
947
|
setJumping(false)
|
|
911
|
-
|
|
948
|
+
setJumpPages(0)
|
|
949
|
+
setJumpElapsed(0)
|
|
950
|
+
setJumpChain(0)
|
|
912
951
|
setBusyID("")
|
|
913
952
|
}
|
|
914
953
|
}
|
|
@@ -953,7 +992,8 @@ function Rail(props) {
|
|
|
953
992
|
}
|
|
954
993
|
>
|
|
955
994
|
<span class="sr-title sr-progress">
|
|
956
|
-
跳转中{
|
|
995
|
+
跳转中{jumpPages() > 0 ? ` · 已加载 ${jumpPages()} 页` : ""}
|
|
996
|
+
{jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
|
|
957
997
|
</span>
|
|
958
998
|
</Show>
|
|
959
999
|
<button
|