From 941b19cf05747235afa16c335aebe8355372ec6a Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 12:49:59 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(Graph):=20=E4=BF=AE=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=82=AC=E6=B5=AE=E8=AF=A6=E6=83=85=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E8=A2=AB=20iframe=20=E8=A3=81=E5=89=AA=E5=A4=B1=E6=95=88?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=B0=86=20Log=20=E8=A7=86=E5=9B=BE=E6=9B=B4?= =?UTF-8?q?=E5=90=8D=20Graph=E3=80=81=E8=A7=86=E8=A7=89=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=AE=98=E6=96=B9=20Source=20Control;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:#48「浮层定位修复」将 positionTip/positionCommitTip 横向定位改为 left=window.innerWidth+8, 误以为 webview position:fixed 可越界渲染到编辑器。实则侧边栏 WebviewView 是沙箱 iframe,坐标系 为 iframe 自身视口,该值落到右边界外被裁剪不可见。抽出共用 positionFloat(锚触发元素右侧→越界翻 左→再越界收进视口),彻底修复 CI 与提交两处浮层。 同时按需求将面向用户的「Log」视图更名为「Graph」,并对齐 VS Code 官方 Source Control GRAPH 视图 样式(不改底层数据/协议/布局算法/CI 逻辑): - 泳道连线 直线改三次贝塞尔 平滑曲线(fromCol===toCol 自动退化直线); - 当前 HEAD 行节点渲染为空心环+内点(双环高亮),普通行保持实心点; - 引用胶囊改实心半透明底+同色描边+内联 SVG 图标前缀(分支/云/tag,因无 codicon 字体); - 工具栏 seg 按钮间距/圆角/hover 态贴近官方; - package.json 视图名与 Refresh/Filter/Clear Graph Filter 命令标题、CI 配置描述、aria-label 统一为 Graph(viewType hyperGit.log、log/* 消息前缀、类名等内部标识符不动)。 验证:check-types/lint/生产打包通过,324 单测全绿;贝塞尔几何与 HEAD 判定纯函数 11 项自测通过; 离线 harness 浏览器截图目视对齐官方 GRAPH 视图。 🤖 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 --- package.json | 10 +-- src/adapter/webview/log-webview.ts | 107 +++++++++++++++++++---------- 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 276dd10..358b040 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ }, { "id": "hyperGit.log", - "name": "Log", + "name": "Graph", "type": "webview", "visibility": "visible" }, @@ -167,7 +167,7 @@ }, { "command": "hyperGit.refreshLog", - "title": "Refresh Log", + "title": "Refresh Graph", "category": "Hyper Git", "icon": "$(refresh)" }, @@ -185,7 +185,7 @@ }, { "command": "hyperGit.logFilter", - "title": "Filter Log…", + "title": "Filter Graph…", "category": "Hyper Git", "icon": "$(filter)" }, @@ -201,7 +201,7 @@ }, { "command": "hyperGit.logClearFilter", - "title": "Clear Log Filter", + "title": "Clear Graph Filter", "category": "Hyper Git", "icon": "$(clear-all)" }, @@ -1185,7 +1185,7 @@ "hyperGit.log.ci.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Show the final CI status on each commit in the Log view (GitHub Actions + Commit Statuses). Green check = passed, red cross = failed; hover to see each check and any failure reasons. Requires a GitHub remote." + "markdownDescription": "Show the final CI status on each commit in the Graph view (GitHub Actions + Commit Statuses). Green check = passed, red cross = failed; hover to see each check and any failure reasons. Requires a GitHub remote." }, "hyperGit.log.ci.remote": { "type": "string", diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 885b289..fa2944c 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -411,10 +411,11 @@ ${getBaseStyles()} :root { --hg-row: 24px; --hg-lane: 14px; } * { box-sizing: border-box; } body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-sideBar-background); display: flex; flex-direction: column; height: 100vh; overflow: hidden; } -.toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--vscode-editorWidget-border, rgba(128,128,128,.25)); } -.seg { display: inline-flex; border: 1px solid var(--vscode-input-border, transparent); border-radius: 3px; overflow: hidden; } -.seg button { background: transparent; color: var(--vscode-foreground); border: none; padding: 2px 8px; font-size: 11px; cursor: pointer; opacity: 0.7; } -.seg button.active { background: var(--vscode-button-background); color: var(--vscode-button-foreground); opacity: 1; } +.toolbar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; border-bottom: 1px solid var(--vscode-editorWidget-border, rgba(128,128,128,.25)); } +.seg { display: inline-flex; border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; overflow: hidden; } +.seg button { background: transparent; color: var(--vscode-foreground); border: none; padding: 2px 9px; font-size: 11px; cursor: pointer; opacity: 0.65; transition: background-color .12s ease, opacity .12s ease; } +.seg button:hover { background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); opacity: 0.9; } +.seg button.active { background: var(--vscode-inputOption-activeBackground, var(--vscode-button-background)); color: var(--vscode-inputOption-activeForeground, var(--vscode-button-foreground)); opacity: 1; } .repo { margin-left: auto; font-size: 10px; opacity: 0.55; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #viewport { flex: 1; overflow-y: auto; overflow-x: hidden; position: relative; outline: none; } #spacer { position: relative; } @@ -424,17 +425,23 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod .row.selected { background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-inactiveSelectionBackground)); } .row svg.graph { flex: 0 0 auto; display: block; } .row svg.graph .node { stroke: var(--vscode-sideBar-background); stroke-width: 1.5; } +.row svg.graph .node-dot { stroke: var(--vscode-sideBar-background); stroke-width: 1; } .row.selected svg.graph .node { stroke: var(--vscode-focusBorder); stroke-width: 2.2; } +.row.selected svg.graph .node-ring { stroke: var(--vscode-focusBorder); stroke-width: 2; } .subject { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 4px; } .msg { overflow: hidden; text-overflow: ellipsis; } .merge { opacity: 0.6; font-size: 10px; padding: 0 2px; } -.chips { display: inline-flex; gap: 3px; flex: 0 1 auto; min-width: 0; overflow: hidden; } -.chip { font-size: 10px; padding: 0 5px; border-radius: 8px; border: 1px solid transparent; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; } -.chip.head { background: var(--vscode-statusBarItem-prominentBackground, var(--vscode-button-background)); color: var(--vscode-statusBarItem-prominentForeground, var(--vscode-button-foreground)); font-weight: 600; } -.chip.localBranch { color: var(--vscode-charts-blue, #58a6ff); border-color: var(--vscode-charts-blue, #58a6ff); } +/* 引用胶囊:实心半透明底 + 同色描边 + 图标前缀(对齐官方 GRAPH 视图)。半透明底用固定 rgba 稳对比度,文字/描边走主题 --vscode-charts-* 令牌。 */ +.chips { display: inline-flex; gap: 4px; flex: 0 1 auto; min-width: 0; overflow: hidden; } +.chip { display: inline-flex; align-items: center; gap: 3px; height: 15px; line-height: 15px; font-size: 10px; padding: 0 6px 0 5px; border-radius: 3px; border: 1px solid transparent; white-space: nowrap; max-width: 140px; overflow: hidden; text-overflow: ellipsis; } +.chip .chip-ico { flex: 0 0 auto; width: 11px; height: 11px; display: inline-flex; } +.chip .chip-ico svg { width: 11px; height: 11px; display: block; } +.chip .chip-nm { overflow: hidden; text-overflow: ellipsis; } +.chip.localBranch { background: rgba(55,148,255,.16); color: var(--vscode-charts-blue, #3794ff); border-color: rgba(55,148,255,.42); } +.chip.remoteBranch { background: rgba(177,128,215,.16); color: var(--vscode-charts-purple, #b180d7); border-color: rgba(177,128,215,.40); } +.chip.tag { background: rgba(210,153,34,.16); color: var(--vscode-charts-yellow, #d29922); border-color: rgba(210,153,34,.42); } .chip.head-target { font-weight: 700; } -.chip.remoteBranch { color: var(--vscode-descriptionForeground, #8b949e); border-color: var(--vscode-descriptionForeground, #8b949e); } -.chip.tag { color: var(--vscode-charts-yellow, #d29922); border-color: var(--vscode-charts-yellow, #d29922); } +.chip.head, .chip.localBranch.head-target { background: var(--vscode-charts-blue, #3794ff); color: #fff; border-color: transparent; font-weight: 600; } .author { flex: 0 0 auto; font-size: 11px; opacity: 0.7; max-width: 110px; overflow: hidden; text-overflow: ellipsis; padding-left: 8px; } .date { flex: 0 0 auto; font-size: 11px; opacity: 0.55; padding-left: 8px; } #viewport.narrow .author, #viewport.narrow .date { display: none; } @@ -517,7 +524,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod -
+
No Commits
No commits match the current scope or filter.
@@ -589,6 +596,12 @@ const errorMsgEl = document.getElementById('error-msg'); const retryBtnEl = document.getElementById('retry-btn'); function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +// 引用胶囊图标(内联 SVG,仿 codicon git-branch / cloud / tag;fill=currentColor 继承 chip 前景色)。 +// 项目未引入 codicon 字体(localResourceRoots=[]、CSP 无 font-src),故图标一律内联,与 ciGlyph 一致。 +const ICO_BRANCH = ''; +const ICO_CLOUD = ''; +const ICO_TAG = ''; +function chipIcon(kind) { return kind === 'remoteBranch' ? ICO_CLOUD : kind === 'tag' ? ICO_TAG : ICO_BRANCH; } function laneColor(i) { return PALETTE[((i % PALETTE.length) + PALETTE.length) % PALETTE.length]; } function colX(c) { return c * LANE_W + LANE_W / 2; } /** 本行实际绘制的最右列号(node + 各边 from/to 的最大值)——行宽据此自适应,消除「全局 maxLanes 撑宽」的留白。 */ @@ -611,18 +624,39 @@ function rowSvg(row) { const cy = ROW_H / 2; const W = (rowMaxCol(row) + 1) * LANE_W + GUTTER; const p = ['']; - const seg = (e) => 'stroke="' + laneColor(e.colorIdx) + '" stroke-width="1.6" stroke-linecap="round"'; - for (const e of L.passThrough) p.push(''); - for (const e of L.incoming) p.push(''); + // 泳道连线用三次贝塞尔平滑过渡(对齐官方 GRAPH 视图):控制点取 y 中点、各自锚原 x; + // fromCol===toCol 时自动退化为直线(直行/贯穿/dangling 竖段无需特判)。fill="none" 为 path 必需,避免闭合填充。 + const seg = (e) => 'fill="none" stroke="' + laneColor(e.colorIdx) + '" stroke-width="1.6" stroke-linecap="round"'; + for (const e of L.passThrough) p.push(''); + for (const e of L.incoming) p.push(''); for (const e of L.outgoing) { const y2 = e.kind === 'dangling' ? ROW_H * 0.78 : ROW_H; const op = e.kind === 'dangling' ? ' opacity="0.45"' : ''; - p.push(''); + p.push(''); + } + // 节点:当前 HEAD 行绘「空心环 + 内点」(双环高亮,对齐官方),普通行绘实心点。环 fill=none 让贯穿竖线透过可见。 + const nx = colX(L.node.col), col = laneColor(L.node.colorIdx); + if (isHeadRow(row)) { + // 环/内点不挂 .node 类:避免通用 .node { stroke: sideBar-background } 覆盖内联 lane 色 stroke(SVG presentation 属性优先级低于 CSS)。 + p.push(''); + p.push(''); + } else { + p.push(''); } - p.push(''); p.push(''); return p.join(''); } +/** S 形三次贝塞尔:控制点在 y 中点、各自锚原 x。fromCol===toCol 时退化为竖直直线。 */ +function edgePath(x1, y1, x2, y2) { + const my = (y1 + y2) / 2; + return 'M' + x1 + ' ' + y1 + ' C' + x1 + ' ' + my + ' ' + x2 + ' ' + my + ' ' + x2 + ' ' + y2; +} +/** 当前 HEAD 行判定:chips 中有 detached HEAD(kind==='head')或 HEAD 指向的本地分支(isHeadTarget)。 */ +function isHeadRow(row) { + const cs = row.chips || []; + for (const c of cs) { if (c.kind === 'head' || c.isHeadTarget) return true; } + return false; +} function chipsHtml(row) { if (!row.chips || row.chips.length === 0) return ''; @@ -630,7 +664,8 @@ function chipsHtml(row) { for (const c of row.chips) { const cls = 'chip ' + c.kind + (c.isHeadTarget ? ' head-target' : ''); // 不加原生 title:引用明细统一由自定义 #commit-tip 浮层展示,避免原生+自定义双重气泡。 - parts.push('', esc(c.name), ''); + // 图标前缀(分支/云/tag)对齐官方 GRAPH 视图的引用胶囊。 + parts.push('', chipIcon(c.kind), '', esc(c.name), ''); } parts.push(''); return parts.join(''); @@ -808,17 +843,23 @@ function buildTip(ci) { if (ci.url) parts.push(''); ciTipEl.innerHTML = parts.join(''); } -function positionTip(rect) { - ciTipEl.style.display = 'flex'; - const th = ciTipEl.offsetHeight; - const vh = window.innerHeight, pad = 6; - // 横向:浮到侧边栏右侧(编辑器区内),不再压住 LOG 列表本身;webview 的 position:fixed 可越界渲染到编辑器。 - ciTipEl.style.left = (window.innerWidth + 8) + 'px'; - // 纵向:与所悬图标顶部对齐,超出视口下沿则上移。 +// 浮层定位(CI / 提交详情共用):webview 是沙箱 iframe,position:fixed 相对 iframe 自身视口, +// 越界坐标(如 innerWidth+8)会被 iframe 裁剪不可见。故一律锚在触发元素右侧,越右翻左,仍越界则收进视口。 +function positionFloat(el, rect) { + el.style.display = 'flex'; + const w = el.offsetWidth, h = el.offsetHeight; + const vw = window.innerWidth, vh = window.innerHeight, pad = 6, gap = 8; + // 横向:默认贴触发元素右侧;越右沿翻到左侧;仍越界则收进视口。 + let left = rect.right + gap; + if (left + w > vw - pad) left = rect.left - gap - w; + if (left < pad) left = Math.max(pad, vw - pad - w); + el.style.left = left + 'px'; + // 纵向:与触发元素顶部对齐,超出视口下沿则上移。 let top = rect.top; - if (top + th > vh - pad) top = Math.max(pad, vh - pad - th); - ciTipEl.style.top = top + 'px'; + if (top + h > vh - pad) top = Math.max(pad, vh - pad - h); + el.style.top = top + 'px'; } +function positionTip(rect) { positionFloat(ciTipEl, rect); } function scheduleShow(hash, iconEl) { clearTimeout(tipHideT); if (tipHash === hash && ciTipEl.classList.contains('show')) return; @@ -1005,7 +1046,7 @@ function buildCommitTip(row) { const kind = g[0]; const chips = (row.chips || []).filter(function (c) { return c.kind === kind; }); if (chips.length === 0) continue; - const inner = chips.map(function (c) { return '' + esc(c.name) + ''; }).join(''); + const inner = chips.map(function (c) { return '' + chipIcon(kind) + '' + esc(c.name) + ''; }).join(''); parts.push('
' + g[1] + '' + inner + '
'); } let msg = '
' + esc(row.subject) + ''; @@ -1028,17 +1069,7 @@ function buildCommitTip(row) { parts.push('
'); commitTipEl.innerHTML = parts.join(''); } -function positionCommitTip(rect) { - commitTipEl.style.display = 'flex'; - const th = commitTipEl.offsetHeight; - const vh = window.innerHeight, pad = 6; - // 横向:浮到侧边栏右侧(编辑器区内),不再压住 LOG 列表本身;webview 的 position:fixed 可越界渲染到编辑器。 - commitTipEl.style.left = (window.innerWidth + 8) + 'px'; - // 纵向:与所悬行顶部对齐,超出视口下沿则上移。 - let top = rect.top; - if (top + th > vh - pad) top = Math.max(pad, vh - pad - th); - commitTipEl.style.top = top + 'px'; -} +function positionCommitTip(rect) { positionFloat(commitTipEl, rect); } function scheduleShowCommit(hash, rowEl) { clearTimeout(ctHideT); if (ctHash === hash && commitTipEl.classList.contains('show')) return; From 6a0105b6175499619a5df2618b9dac6fd13467aa Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 13:37:21 +0800 Subject: [PATCH 2/8] =?UTF-8?q?style(Graph):=20=E5=BC=95=E7=94=A8=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E5=AE=8C=E5=85=A8=E5=AF=B9=E9=BD=90=E5=AE=98=E6=96=B9?= =?UTF-8?q?=20GRAPH=E2=80=94=E2=80=94=E7=A7=BB=E8=87=B3=20message=20?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E5=90=8E=E7=BC=80=E3=80=81=E5=BA=95=E8=89=B2?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E6=B3=B3=E9=81=93=E8=89=B2=E3=80=81=E6=94=B9?= =?UTF-8?q?=E5=85=A8=E5=9C=86=E8=A7=92=E5=AE=9E=E5=BF=83=20pill;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 复核发现上一轮 chips 与官方 Source Control GRAPH 视图存在三处实质偏离,本次逐项对齐(截图逐行比对官方图确认): - 位置:chips 从 message 左侧前缀移到右侧后缀(列序 泳道图→message→chips→author→date→CI); - 颜色:底色改为跟随本行泳道色 laneColor(row.layout.node.colorIdx),类型靠图标区分而非颜色, 与官方一致(同名 origin 远程分支随所在行泳道呈多色);新增 onColor() 按底色亮度自适应深/白字保可读; - 形状:由半透明小圆角矩形(radius 3px)改为实心不透明全圆角 pill(radius 8px),保留分支/云/tag 图标前缀; - #commit-tip 浮层内胶囊同步同款样式,与行内保持一致。 仅渲染层调整:chipsHtml/rowHtml/buildCommitTip + CSS,未触碰数据/协议/布局算法/CI 逻辑。 验证:check-types/lint/生产打包通过,324 单测全绿;DOM 结构断言(chips 在 msg 后、颜色跟泳道、 HEAD 空心环)+ Chrome headless 截图与官方图 3 逐行比对确认对齐。 🤖 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 | 40 ++++++++++++++++++------------ 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index fa2944c..42772ae 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -428,20 +428,15 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod .row svg.graph .node-dot { stroke: var(--vscode-sideBar-background); stroke-width: 1; } .row.selected svg.graph .node { stroke: var(--vscode-focusBorder); stroke-width: 2.2; } .row.selected svg.graph .node-ring { stroke: var(--vscode-focusBorder); stroke-width: 2; } -.subject { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 4px; } -.msg { overflow: hidden; text-overflow: ellipsis; } -.merge { opacity: 0.6; font-size: 10px; padding: 0 2px; } -/* 引用胶囊:实心半透明底 + 同色描边 + 图标前缀(对齐官方 GRAPH 视图)。半透明底用固定 rgba 稳对比度,文字/描边走主题 --vscode-charts-* 令牌。 */ -.chips { display: inline-flex; gap: 4px; flex: 0 1 auto; min-width: 0; overflow: hidden; } -.chip { display: inline-flex; align-items: center; gap: 3px; height: 15px; line-height: 15px; font-size: 10px; padding: 0 6px 0 5px; border-radius: 3px; border: 1px solid transparent; white-space: nowrap; max-width: 140px; overflow: hidden; text-overflow: ellipsis; } +.subject { flex: 1 1 auto; min-width: 0; overflow: hidden; display: flex; align-items: center; gap: 6px; } +.msg { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.merge { flex: 0 0 auto; opacity: 0.6; font-size: 10px; padding: 0 2px; } +/* 引用胶囊:实心圆角 pill + 图标前缀,底色跟随本行泳道色(内联 style 注入),类型靠图标区分(对齐官方 GRAPH 视图)。 */ +.chips { display: inline-flex; gap: 4px; flex: 0 0 auto; min-width: 0; overflow: hidden; } +.chip { display: inline-flex; align-items: center; gap: 3px; height: 16px; line-height: 16px; font-size: 10px; font-weight: 600; padding: 0 7px 0 6px; border-radius: 8px; white-space: nowrap; max-width: 160px; overflow: hidden; text-overflow: ellipsis; } .chip .chip-ico { flex: 0 0 auto; width: 11px; height: 11px; display: inline-flex; } .chip .chip-ico svg { width: 11px; height: 11px; display: block; } .chip .chip-nm { overflow: hidden; text-overflow: ellipsis; } -.chip.localBranch { background: rgba(55,148,255,.16); color: var(--vscode-charts-blue, #3794ff); border-color: rgba(55,148,255,.42); } -.chip.remoteBranch { background: rgba(177,128,215,.16); color: var(--vscode-charts-purple, #b180d7); border-color: rgba(177,128,215,.40); } -.chip.tag { background: rgba(210,153,34,.16); color: var(--vscode-charts-yellow, #d29922); border-color: rgba(210,153,34,.42); } -.chip.head-target { font-weight: 700; } -.chip.head, .chip.localBranch.head-target { background: var(--vscode-charts-blue, #3794ff); color: #fff; border-color: transparent; font-weight: 600; } .author { flex: 0 0 auto; font-size: 11px; opacity: 0.7; max-width: 110px; overflow: hidden; text-overflow: ellipsis; padding-left: 8px; } .date { flex: 0 0 auto; font-size: 11px; opacity: 0.55; padding-left: 8px; } #viewport.narrow .author, #viewport.narrow .date { display: none; } @@ -603,6 +598,14 @@ const ICO_CLOUD = ''; function chipIcon(kind) { return kind === 'remoteBranch' ? ICO_CLOUD : kind === 'tag' ? ICO_TAG : ICO_BRANCH; } function laneColor(i) { return PALETTE[((i % PALETTE.length) + PALETTE.length) % PALETTE.length]; } +// 实心胶囊前景色:按底色相对亮度择深/白字(WCAG 阈值 0.6),保证任意泳道底色上文字均可读。解析失败回落白字。 +function onColor(bg) { + const m = /^#?([0-9a-f]{6})$/i.exec(String(bg).trim()); + if (!m) return '#ffffff'; + const n = parseInt(m[1], 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return lum > 0.6 ? '#1e1e1e' : '#ffffff'; +} function colX(c) { return c * LANE_W + LANE_W / 2; } /** 本行实际绘制的最右列号(node + 各边 from/to 的最大值)——行宽据此自适应,消除「全局 maxLanes 撑宽」的留白。 */ function rowMaxCol(row) { const L = row.layout; let m = L.node.col; for (const e of L.incoming) { if (e.fromCol > m) m = e.fromCol; if (e.toCol > m) m = e.toCol; } for (const e of L.outgoing) { if (e.fromCol > m) m = e.fromCol; if (e.toCol > m) m = e.toCol; } for (const e of L.passThrough) { if (e.fromCol > m) m = e.fromCol; if (e.toCol > m) m = e.toCol; } return m; } @@ -660,12 +663,14 @@ function isHeadRow(row) { function chipsHtml(row) { if (!row.chips || row.chips.length === 0) return ''; + // 对齐官方 GRAPH:胶囊实心底色跟随本行泳道色(node.colorIdx),类型靠图标(分支/云/tag)区分而非颜色; + // 文字色按底色亮度自适应,保证可读。不加原生 title:引用明细统一由 #commit-tip 浮层展示。 + const bg = laneColor(row.layout.node.colorIdx); + const fg = onColor(bg); const parts = ['']; for (const c of row.chips) { const cls = 'chip ' + c.kind + (c.isHeadTarget ? ' head-target' : ''); - // 不加原生 title:引用明细统一由自定义 #commit-tip 浮层展示,避免原生+自定义双重气泡。 - // 图标前缀(分支/云/tag)对齐官方 GRAPH 视图的引用胶囊。 - parts.push('', chipIcon(c.kind), '', esc(c.name), ''); + parts.push('', chipIcon(c.kind), '', esc(c.name), ''); } parts.push(''); return parts.join(''); @@ -695,9 +700,10 @@ function ciSlotHtml(row) { function rowHtml(row, idx) { const sel = row.hash === selectedHash ? ' selected' : ''; const merge = row.isMerge ? '' : ''; + // 列顺序对齐官方 GRAPH:泳道图 → message → 引用胶囊 → author → date → CI。chips 作为 message 右侧后缀。 return '
' + rowSvg(row) - + '' + chipsHtml(row) + '' + esc(row.subject) + '' + merge + '' + + '' + esc(row.subject) + '' + merge + chipsHtml(row) + '' + '' + esc(row.authorName) + '' + '' + fmtDate(row.authorDate) + '' + ciSlotHtml(row) @@ -1042,11 +1048,13 @@ function renderDetails(hash, files, tree) { function buildCommitTip(row) { const parts = ['
']; const refGroups = [['head', 'HEAD'], ['localBranch', 'Branches'], ['remoteBranch', 'Remotes'], ['tag', 'Tags']]; + const tipBg = laneColor(row.layout.node.colorIdx), tipFg = onColor(tipBg); for (const g of refGroups) { const kind = g[0]; const chips = (row.chips || []).filter(function (c) { return c.kind === kind; }); if (chips.length === 0) continue; - const inner = chips.map(function (c) { return '' + chipIcon(kind) + '' + esc(c.name) + ''; }).join(''); + // 浮层内胶囊与行内保持一致:实心 pill、底色跟随泳道色、图标前缀。 + const inner = chips.map(function (c) { return '' + chipIcon(kind) + '' + esc(c.name) + ''; }).join(''); parts.push('
' + g[1] + '' + inner + '
'); } let msg = '
' + esc(row.subject) + ''; From 21fd7d03ddce23a398548e093d84e16be966fc5f Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 16:23:47 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(Graph):=20=E6=8F=90=E4=BA=A4=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=B5=AE=E5=B1=82=E8=BF=81=E7=A7=BB=E8=87=B3=E5=8F=B3?= =?UTF-8?q?=E4=BE=A7=E7=BC=96=E8=BE=91=E5=99=A8=E5=8C=BA=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=E5=AE=98=E6=96=B9=20Source=20Contro?= =?UTF-8?q?l=20Graph;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:悬停 GRAPH 列表某 commit 时,在右侧编辑器区展示该提交详情面板(对齐官方 Source Control Graph 的 hover 面板位置)。原实现是侧栏 iframe 内 position:fixed 浮层——受浏览器沙箱限制,DOM 浮层无法渲染到 iframe 之外的编辑器区,此路架构上封死,故改用 WebviewPanel。 实现(仅渲染/呈现层,不改数据/协议既有分支/布局算法/CI 逻辑): - 新增 CommitDetailPanel(editor 区 WebviewPanel,ViewColumn.Beside + preserveFocus 不抢焦点): 单例复用,悬停切换只 postMessage 换数据 + reveal,不反复 create/dispose;reqSeq 竞态守卫 + lastHash 去重;onDidDispose 置空、下次悬停重建。面板内容 = 头像占位+作者+相对/绝对时间、完整 message、变更统计(N files changed/+ins/-del)、完整 hash + Open on GitHub。 - GRAPH 悬停 400ms 防抖 → 发 log/showCommitDetail;移除旧侧栏浮层 #commit-tip(CSS/DOM/JS 约 20 处),CI 浮层 #ci-tip 保持不动;i 键改为对选中提交打开面板(保留键盘可达)。 - engine 纯函数(零 vscode,可单测):parseShortStat(shortstat 解析)、commitWebUrl(GitHub 提交 页 URL)、format-time(相对/绝对时间,host 侧预格式化下发)。github-ci-service 暴露 public getGitHubRemote 复用远程解析缓存;Open on GitHub 走既有 openExternal(SSRF 白名单已放行 web 域)。 - protocol 扩展 CommitDetailVM/Stat + log/showCommitDetail + commitDetail/data、openExternal 消息。 验证:check-types/lint/生产打包通过;单测 341 全绿(新增 shortstat/commitWebUrl/format-time 17 例);panel 离线 harness 浏览器截图与官方图逐项比对对齐。注:编辑器区面板生命周期/悬停/不抢焦点 需真实 VS Code F5 端到端确认(webview 交互离线不可覆盖)。 🤖 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/ci/github-ci-service.ts | 5 + src/adapter/webview/commit-detail-panel.ts | 192 +++++++++++++++++++++ src/adapter/webview/log-webview.ts | 116 ++----------- src/engine/ci/remote-parser.ts | 8 + src/engine/log/commit-files.ts | 36 ++++ src/engine/log/format-time.ts | 43 +++++ src/shared/protocol.ts | 45 ++++- tests/unit/ci-remote-parser.test.ts | 13 +- tests/unit/log-commit-files.test.ts | 37 +++- tests/unit/log-format-time.test.ts | 40 +++++ 10 files changed, 434 insertions(+), 101 deletions(-) create mode 100644 src/adapter/webview/commit-detail-panel.ts create mode 100644 src/engine/log/format-time.ts create mode 100644 tests/unit/log-format-time.test.ts diff --git a/src/adapter/ci/github-ci-service.ts b/src/adapter/ci/github-ci-service.ts index 7222bf3..989562d 100644 --- a/src/adapter/ci/github-ci-service.ts +++ b/src/adapter/ci/github-ci-service.ts @@ -246,6 +246,11 @@ export class GitHubCiService implements vscode.Disposable { return authProviderId(remote); } + /** 对外暴露当前仓库的 GitHub 坐标(复用内部 repoRoot 缓存),供 Commit 详情面板生成提交页 URL。 */ + getGitHubRemote(): GitHubRemote | null { + return this.resolveRemote(); + } + /** 解析当前仓库远程为 GitHub 坐标(按 repoRoot 缓存;优先配置名 → origin → 首个可解析)。 */ private resolveRemote(): GitHubRemote | null { const repoRoot = this.service.repoRoot ?? ''; diff --git a/src/adapter/webview/commit-detail-panel.ts b/src/adapter/webview/commit-detail-panel.ts new file mode 100644 index 0000000..37db196 --- /dev/null +++ b/src/adapter/webview/commit-detail-panel.ts @@ -0,0 +1,192 @@ +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; +import type { GitRepositoryService } from '../git-repository-service'; +import type { GitHubCiService } from '../ci/github-ci-service'; +import type { CommitDetailVM, CommitDetailWebviewToHostMessage } from '../../shared/protocol'; +import { parseShortStat } from '../../engine/log/commit-files'; +import { commitWebUrl } from '../../engine/ci/remote-parser'; +import { formatRelative, formatAbsolute } from '../../engine/log/format-time'; +import { getBaseStyles } from './shared-styles'; + +/** git show 单条元信息的字段分隔符(NUL,与 log-line LOG_GRAPH_FORMAT 同范式)。 */ +const SHOW_FORMAT = '%H%x00%s%x00%b%x00%an%x00%ae%x00%aI%x00%cn%x00%cI%x00%P'; +const BODY_CAP = 4000; + +/** + * Commit 详情面板(编辑器区 WebviewPanel,对齐官方 Source Control Graph 的提交详情)。 + * + * 交互:GRAPH 侧栏悬停提交 → host 组装 {@link CommitDetailVM} → 本面板在编辑器区(Beside)显示, + * `preserveFocus` 不抢焦点;悬停切换只 `postMessage` 换数据 + `reveal`,不反复 create/dispose。 + * + * 分层:数据取用 host 侧 git(adapter),格式化/解析复用 engine 纯函数;面板 webview 仅负责渲染。 + * 单例:跨悬停复用同一 panel(`current`),用户手动关闭 → `onDidDispose` 置空 → 下次悬停重建。 + */ +export class CommitDetailPanel { + private static current: CommitDetailPanel | undefined; + private readonly panel: vscode.WebviewPanel; + private disposed = false; + private reqSeq = 0; + private lastHash: string | undefined; + + private constructor( + private readonly service: GitRepositoryService, + private readonly ciService: GitHubCiService, + ) { + this.panel = vscode.window.createWebviewPanel( + 'hyperGit.commitDetail', + 'Commit', + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [] }, + ); + this.panel.webview.html = CommitDetailPanel.renderShellHtml(); + this.panel.webview.onDidReceiveMessage((m: CommitDetailWebviewToHostMessage) => { + if (m?.type === 'commitDetail/openExternal') { + void this.ciService.openExternal(m.payload.url); + } + }); + this.panel.onDidDispose(() => { + this.disposed = true; + if (CommitDetailPanel.current === this) { + CommitDetailPanel.current = undefined; + } + }); + } + + /** 显示(或更新)指定提交的详情面板。单例复用,不抢焦点。 */ + static async show(service: GitRepositoryService, ciService: GitHubCiService, hash: string): Promise { + if (!CommitDetailPanel.current) { + CommitDetailPanel.current = new CommitDetailPanel(service, ciService); + } + await CommitDetailPanel.current.update(hash); + } + + private async update(hash: string): Promise { + if (this.lastHash === hash && this.panel.visible) { + return; // 已在显示同一提交(连续悬停/方向键去重)。 + } + const seq = ++this.reqSeq; + const vm = await this.buildDetailVM(hash); + if (this.disposed || seq !== this.reqSeq) { + return; // 面板已关闭,或已被更晚的悬停请求超越 → 丢弃过期响应。 + } + this.lastHash = vm ? hash : undefined; + this.panel.title = vm ? `${vm.shortHash} · Commit` : 'Commit'; + void this.panel.webview.postMessage({ type: 'commitDetail/data', payload: vm }); + this.panel.reveal(vscode.ViewColumn.Beside, /* preserveFocus */ true); + } + + /** host 侧一次取齐:基础字段(git show)+ 预格式化时间 + 变更统计 + 可选 GitHub 提交页 URL。 */ + private async buildDetailVM(hash: string): Promise { + if (!this.service.repo) { + return null; + } + try { + const raw = await this.service.execGit(['show', '-s', `--format=${SHOW_FORMAT}`, hash]); + const f = raw.split('\0'); + if (f.length < 9 || !f[0]) { + return null; + } + const [fullHash, subject, body, authorName, authorEmail, authorDate, committerName, committerDate, parentsRaw] = f; + const stat = parseShortStat( + await this.service.execGit(['diff-tree', '--no-commit-id', '--shortstat', '-r', '--root', hash]), + ); + const remote = this.ciService.getGitHubRemote(); + const cappedBody = body.length > BODY_CAP ? `${body.slice(0, BODY_CAP)}…` : body.replace(/\s+$/, ''); + return { + hash: fullHash, + shortHash: fullHash.slice(0, 7), + subject, + body: cappedBody, + authorName, + authorEmail, + authorDate, + authorDateRel: formatRelative(authorDate), + authorDateAbs: formatAbsolute(authorDate), + committerName, + committerDate, + parents: parentsRaw ? parentsRaw.trim().split(/\s+/).filter(Boolean) : [], + stat, + githubUrl: remote ? commitWebUrl(remote, fullHash) : undefined, + }; + } catch { + return null; // 坏 hash / git 失败 → 面板空态,不弹模态(对齐 sendCommitFiles 静默降级)。 + } + } + + private static renderShellHtml(): string { + const nonce = crypto.randomBytes(16).toString('base64'); + const csp = ['default-src \'none\'', 'style-src \'unsafe-inline\'', `script-src 'nonce-${nonce}'`].join('; '); + return ` + + + + + + + +
Hover a commit in the Graph to see its details.
+
+ + +`; + } +} diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 42772ae..89cbf82 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -10,6 +10,7 @@ import { parseLogLines } from '../../engine/log/log-line'; import { buildLogArgs, type LogScope } from '../../engine/log/log-query'; import { buildFileTree } from '../../engine/tree/file-tree'; import type { GitHubCiService } from '../ci/github-ci-service'; +import { CommitDetailPanel } from './commit-detail-panel'; import type { CiMetaVM, CiStatusVM, @@ -186,6 +187,9 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter case 'log/ciSignIn': void this.handleCiSignIn(); break; + case 'log/showCommitDetail': + void CommitDetailPanel.show(this.service, this.ciService, msg.payload.hash); + break; } } @@ -495,18 +499,6 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod #details .tree-dir:hover { background: var(--vscode-list-hoverBackground); } #details .tree-dir .tree-twist { flex: 0 0 12px; text-align: center; font-size: 10px; opacity: 0.8; } #details .tree-dir .tree-name { color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; } -/* ── 提交悬浮详情(自定义浮层,置于 #rows 之外,虚拟滚动重写不销毁;仿 CI Tooltip)── */ -#commit-tip { position: fixed; z-index: 50; display: none; max-width: 420px; min-width: 260px; max-height: 360px; overflow: hidden; background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border, rgba(128,128,128,.3))); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,.35); font-size: 12px; } -#commit-tip.show { display: flex; flex-direction: column; } -#commit-tip .ct-scroll { overflow-y: auto; max-height: 360px; padding: 4px 0; } -#commit-tip .ct-sec { padding: 4px 10px; display: flex; gap: 8px; align-items: baseline; } -#commit-tip .ct-sec .ct-k { flex: 0 0 62px; color: var(--vscode-descriptionForeground); font-size: 10px; text-transform: uppercase; letter-spacing: .3px; } -#commit-tip .ct-sec .ct-v { flex: 1 1 auto; min-width: 0; word-break: break-word; } -#commit-tip .ct-msg .ct-subj { font-weight: 600; display: block; margin-bottom: 3px; } -#commit-tip .ct-msg .ct-body { white-space: pre-wrap; word-break: break-word; opacity: 0.92; } -#commit-tip .ct-refs { display: flex; flex-wrap: wrap; gap: 3px; } -#commit-tip .ct-div { border-top: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); margin: 2px 0; } -#commit-tip .ct-sha { font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; opacity: 0.85; word-break: break-all; } @@ -527,7 +519,6 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod
- - -`; - } -} diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 89cbf82..1d3666a 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -1,7 +1,7 @@ import * as crypto from 'crypto'; import * as vscode from 'vscode'; import type { GitRepositoryService } from '../git-repository-service'; -import { parseNameStatus, statusLabel } from '../../engine/log/commit-files'; +import { parseNameStatus, statusLabel, parseShortStat } from '../../engine/log/commit-files'; import { applyClientFilters, toClientFilter, type LogFilter } from '../../engine/log/log-filter'; import { DEFAULT_LANE_PALETTE } from '../../engine/log/graph-color'; import { computeGraphLayout, maxLanes } from '../../engine/log/graph-layout'; @@ -9,11 +9,13 @@ import { getBaseStyles } from './shared-styles'; import { parseLogLines } from '../../engine/log/log-line'; import { buildLogArgs, type LogScope } from '../../engine/log/log-query'; import { buildFileTree } from '../../engine/tree/file-tree'; +import { formatRelative, formatAbsolute } from '../../engine/log/format-time'; +import { commitWebUrl } from '../../engine/ci/remote-parser'; import type { GitHubCiService } from '../ci/github-ci-service'; -import { CommitDetailPanel } from './commit-detail-panel'; import type { CiMetaVM, CiStatusVM, + CommitDetailVM, GraphRowVM, LogCommitFileItem, LogGraphState, @@ -188,11 +190,54 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter void this.handleCiSignIn(); break; case 'log/showCommitDetail': - void CommitDetailPanel.show(this.service, this.ciService, msg.payload.hash); + void this.showCommitDetail(msg.payload.hash); break; } } + /** 组装提交详情 VM(基础字段 + 预格式化时间 + 变更统计 + GitHub URL),下发给 webview 浮层渲染。 */ + private async showCommitDetail(hash: string): Promise { + if (!this.service.repo) { + this.post({ type: 'log/commitDetail', payload: { vm: null } }); + return; + } + try { + // %x00 分隔,与 LOG_GRAPH_FORMAT 同范式;单条 git show 开销极小。 + const fmt = '%H%x00%s%x00%b%x00%an%x00%ae%x00%aI%x00%cn%x00%cI%x00%P'; + const raw = await this.service.execGit(['show', '-s', `--format=${fmt}`, hash]); + const f = raw.split('\0'); + if (f.length < 9 || !f[0]) { + this.post({ type: 'log/commitDetail', payload: { vm: null } }); + return; + } + const [fullHash, subject, body, authorName, authorEmail, authorDate, committerName, committerDate, parentsRaw] = f; + const stat = parseShortStat( + await this.service.execGit(['diff-tree', '--no-commit-id', '--shortstat', '-r', '--root', hash]), + ); + const remote = this.ciService.getGitHubRemote(); + const cappedBody = body.length > 4000 ? `${body.slice(0, 4000)}…` : body.replace(/\s+$/, ''); + const vm: CommitDetailVM = { + hash: fullHash, + shortHash: fullHash.slice(0, 7), + subject, + body: cappedBody, + authorName, + authorEmail, + authorDate, + authorDateRel: formatRelative(authorDate), + authorDateAbs: formatAbsolute(authorDate), + committerName, + committerDate, + parents: parentsRaw ? parentsRaw.trim().split(/\s+/).filter(Boolean) : [], + stat, + githubUrl: remote ? commitWebUrl(remote, fullHash) : undefined, + }; + this.post({ type: 'log/commitDetail', payload: { vm } }); + } catch { + this.post({ type: 'log/commitDetail', payload: { vm: null } }); + } + } + private post(message: LogHostToWebviewMessage): void { this.view?.webview.postMessage(message); } @@ -493,6 +538,29 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod #ci-tip .g-failure { color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground, #f85149)); } #ci-tip .g-pending { color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground, #d29922)); } #ci-tip .g-skipped, #ci-tip .g-unknown { color: var(--vscode-descriptionForeground, #8b949e); } +/* ── 提交详情悬浮卡(cursor-anchored;editorHoverWidget 语义令牌,与 CI 浮层同款视觉语言)── */ +#commit-tip { position: fixed; z-index: 50; display: none; max-width: 480px; min-width: 300px; max-height: 80vh; overflow: hidden; background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border, rgba(128,128,128,.3))); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,.4); font-size: 12px; } +#commit-tip.show { display: flex; flex-direction: column; } +#commit-tip .ct-scroll { overflow-y: auto; max-height: 80vh; padding: 12px 14px; } +#commit-tip .ct-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +#commit-tip .ct-avatar { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; background: var(--vscode-badge-background, rgba(128,128,128,.25)); color: var(--vscode-badge-foreground, var(--vscode-foreground)); display: inline-flex; align-items: center; justify-content: center; } +#commit-tip .ct-avatar svg { width: 16px; height: 16px; opacity: 0.85; } +#commit-tip .ct-who { display: flex; flex-direction: column; min-width: 0; } +#commit-tip .ct-author { font-weight: 600; font-size: 13px; } +#commit-tip .ct-time { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 1px; } +#commit-tip .ct-msg { margin-bottom: 10px; } +#commit-tip .ct-subj { font-size: 13px; font-weight: 600; line-height: 1.4; word-break: break-word; } +#commit-tip .ct-body { margin-top: 6px; white-space: pre-wrap; word-break: break-word; font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; line-height: 1.5; opacity: 0.9; } +#commit-tip .ct-stat { display: flex; gap: 12px; padding: 8px 0; border-top: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); border-bottom: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); font-size: 12px; font-variant-numeric: tabular-nums; } +#commit-tip .ct-stat .files { color: var(--vscode-descriptionForeground); } +#commit-tip .ct-stat .ins { color: var(--vscode-gitDecoration-addedResourceForeground, #3fb950); } +#commit-tip .ct-stat .del { color: var(--vscode-gitDecoration-deletedResourceForeground, #f14c4c); } +#commit-tip .ct-foot { display: flex; align-items: center; gap: 14px; margin-top: 10px; flex-wrap: wrap; } +#commit-tip .ct-sha { font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; color: var(--vscode-descriptionForeground); word-break: break-all; } +#commit-tip .ct-gh { color: var(--vscode-textLink-foreground); cursor: pointer; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; } +#commit-tip .ct-gh:hover { text-decoration: underline; } +#commit-tip .ct-gh:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; border-radius: 2px; } +#commit-tip .ct-gh svg { width: 13px; height: 13px; } /* ── 变更文件目录树(详情面板 Group By Directory 形态)── */ #details .dh .seg { flex: 0 0 auto; } #details .tree-dir { display: flex; align-items: center; gap: 6px; padding: 2px 10px; font-size: 12px; cursor: pointer; user-select: none; } @@ -519,6 +587,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod
+