From dbf14be31fc06f60125a8070dd059570263e4007 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 30 Jun 2026 16:42:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(Log):=20CI=20=E7=8A=B6=E6=80=81=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E9=98=B2=E9=97=AA=E7=83=81=20+=20=E5=87=86=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E5=88=B7=E6=96=B0=E4=B8=8E=20Tooltip=20=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E5=AF=B9=E9=BD=90;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 闪烁根因:log/ciData 每次触发整块 innerHTML 重建、且 graphData 刷新清空 CI 缓存 导致重拉循环 → 全列图标反复消失重现。 - 就地补丁:新增 applyCiData 仅改受影响行 .ci 槽位(状态类不变时保留元素,旋转 动画不重启、零重绘),不再 renderedFirst=-1 重建可见行。 - 稳定缓存:graphData 不再清空 ciByHash(CI 状态以不可变 hash 为键),仅重置请求 去重集合,杜绝每次 git 状态变化引发的重拉。 - 准实时刷新:新增 20s 定时器仅复拉可见行中 pending(运行中)提交(host 30s TTL 网络门控),转终态后停拉;响应经 applyCiData 就地补丁,不闪烁。 - Tooltip 子项颜色:tipGlyph 包裹 .g-{state}(绿/红/黄/灰),与头部及行图标语义一致。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/adapter/webview/log-webview.ts | 93 +++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 00f3544..179b4e4 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -490,12 +490,16 @@ let selectedHash = persisted.selectedHash || null; let scope = normalizeScope(persisted.scope); let model = { rows: [], maxLanes: 0, hasMore: false, repoRoot: '' }; let renderedFirst = -1, renderedLast = -1, fetching = false; -// ── CI 状态(懒加载、仅取可见行;ciByHash 缓存、ciRequested 去重、ciPending 防抖批量)── +// ── CI 状态(懒加载、仅取可见行;ciByHash 稳定缓存、ciRequested 去重、ciPending 防抖批量)── +// ciByHash 跨 graphData 刷新保留(CI 状态以不可变 hash 为键),杜绝每次 git 状态变化引发的重拉闪烁。 const ciByHash = Object.create(null); const ciRequested = new Set(); const ciPending = new Set(); let ciMeta = { available: false, needsSignIn: false, error: '' }; let ciReqTimer = null; +// 准实时刷新:仅对可见行中 pending(运行中)状态定时复拉(host 侧 30s TTL 网络门控),终态不再变。 +let ciPendingRefreshTimer = null; +let ciRefreshing = false; const ciTipEl = document.getElementById('ci-tip'); const ciSignInEl = document.getElementById('ci-signin'); let tipHash = null, tipShowT = null, tipHideT = null, overIcon = false, overTip = false; @@ -611,7 +615,7 @@ function collectCiRequests(f, l) { if (!ciMeta.available) return; for (let i = f; i < l; i++) { const h = model.rows[i] && model.rows[i].hash; - if (!h || (h in ciByHash) || ciRequested.has(h)) continue; + if (!h || (h in ciByHash) || ciRequested.has(h) || ciPending.has(h)) continue; ciRequested.add(h); ciPending.add(h); } @@ -626,7 +630,67 @@ function flushCiRequests() { vscode.postMessage({ type: 'log/requestCi', payload: { hashes: hashes } }); } -// ── CI Tooltip(自定义浮层:列明细 + 失败原因 + 跳转链接)── +/** + * CI 数据到达后**就地**更新可见行图标:只改受影响行的 .ci 槽位(replaceChild/appendChild), + * 绝不重建整行/整页(reduce-reflows),从根源消除「每次 ciData 触发 innerHTML 重写」的全列闪烁。 + * 状态类未变(如 pending 复拉、计数更新)时保留原元素,旋转动画不重启、零重绘。 + */ +function applyCiData(map) { + const changed = Object.keys(map); + if (changed.length === 0) return; + for (const h of changed) { + ciByHash[h] = map[h]; + ciRequested.add(h); + } + // 只遍历已渲染的可见行,命中受影响 hash 即就地标定其 .ci 槽位。 + const kids = rowsEl.children; + for (let i = 0; i < kids.length; i++) { + const rowEl = kids[i]; + const h = rowEl.getAttribute('data-hash'); + if (!(h in map)) continue; + const slot = rowEl.querySelector('.ci'); + const ci = ciByHash[h]; + const wantCls = ci && ci.state !== 'unknown' ? 'ci-' + ci.state : 'ci-empty'; + // 状态类未变(如 pending 复拉、计数更新):保留元素,旋转动画不重启、零重绘。 + if (slot && slot.classList.contains(wantCls)) continue; + const fresh = ciSlotHtml({ hash: h }); + if (slot && slot.outerHTML === fresh) continue; + const tmp = document.createElement('div'); + tmp.innerHTML = fresh; + const newSlot = tmp.firstElementChild; + if (slot) { + if (newSlot) rowEl.replaceChild(newSlot, slot); + else rowEl.removeChild(slot); + } else if (newSlot) { + rowEl.appendChild(newSlot); + } + } +} + +/** 准实时刷新:仅对可见行中 pending(运行中)状态的提交定时复拉,转终态后停拉。host 30s TTL 网络门控。 */ +function ensurePendingRefresh() { + if (ciPendingRefreshTimer) return; + ciPendingRefreshTimer = setInterval(schedulePendingRefresh, 20000); +} +function stopPendingRefresh() { + if (ciPendingRefreshTimer) { clearInterval(ciPendingRefreshTimer); ciPendingRefreshTimer = null; } +} +function schedulePendingRefresh() { + if (!ciMeta.available || ciRefreshing) return; + const total = model.rows.length; + if (total === 0 || renderedFirst < 0) return; + const pending = []; + for (let i = renderedFirst; i < renderedLast && i < total; i++) { + const h = model.rows[i] && model.rows[i].hash; + const ci = h && ciByHash[h]; + if (ci && ci.state === 'pending') pending.push(h); + } + if (pending.length === 0) return; + ciRefreshing = true; + vscode.postMessage({ type: 'log/requestCi', payload: { hashes: pending } }); +} + +// ── CI Tooltip(自定义浮层:列明细 + 失败原因 + 跳转链接,仿 IDEA / GitHub)── function tipGlyph(state) { const svg = (state === 'unknown' || state === 'skipped') ? '' @@ -802,8 +866,8 @@ window.addEventListener('message', function (e) { if (m.type === 'log/graphData') { model = { rows: m.payload.rows, maxLanes: m.payload.maxLanes, hasMore: m.payload.hasMore, repoRoot: m.payload.repoRoot }; scope = m.payload.scope; repoEl.textContent = m.payload.repoRoot; repoEl.title = m.payload.repoRoot; - // 图全量重置 → CI 缓存随之失效(提交集合被替换)。 - for (const k in ciByHash) delete ciByHash[k]; + // 保留 ciByHash 稳定缓存(CI 状态以不可变 hash 为键):图重置只清请求去重集合, + // 已缓存的提交重绘时立即可见图标,避免「清缓存→重拉→整行重建」的闪烁。 ciRequested.clear(); ciPending.clear(); if (ciReqTimer) { clearTimeout(ciReqTimer); ciReqTimer = null; } hideTip(); @@ -822,21 +886,20 @@ window.addEventListener('message', function (e) { } else if (m.type === 'log/ciMeta') { ciMeta = { available: !!m.payload.available, needsSignIn: !!m.payload.needsSignIn, error: m.payload.error || '' }; renderCiMeta(); + if (ciMeta.available) ensurePendingRefresh(); else stopPendingRefresh(); renderedFirst = -1; // 强制重绘可见行(CI 槽位/登录提示出现或消失) scheduleRender(); } else if (m.type === 'log/ciData') { const map = m.payload.map; - let touched = false; - for (const h in map) { ciByHash[h] = map[h]; ciRequested.add(h); touched = true; } - if (touched) { - renderedFirst = -1; scheduleRender(); // 就地重绘可见行图标 - // 数据到达后重锚开启中的 Tooltip(图标新增/pending→终态变化)。 + ciRefreshing = false; + if (Object.keys(map).length === 0) return; + applyCiData(map); // 就地补丁可见行图标,杜绝整行重建闪烁 + // 数据到达后重锚开启中的 Tooltip(图标新增/pending→终态变化)。 + if (tipHash && (tipHash in map) && ciTipEl.classList.contains('show')) { requestAnimationFrame(function () { - if (tipHash && ciTipEl.classList.contains('show')) { - const el = rowsEl.querySelector('[data-ci="' + tipHash.replace(/[^a-f0-9]/gi, '') + '"]'); - if (el) { buildTip(ciByHash[tipHash]); positionTip(el.getBoundingClientRect()); } - else hideTip(); - } + const el = rowsEl.querySelector('[data-ci="' + tipHash.replace(/[^a-f0-9]/gi, '') + '"]'); + if (el) { buildTip(ciByHash[tipHash]); positionTip(el.getBoundingClientRect()); } + else hideTip(); }); } }