diff --git a/src/adapter/webview/commit-detail-panel.ts b/src/adapter/webview/commit-detail-panel.ts deleted file mode 100644 index 37db196..0000000 --- a/src/adapter/webview/commit-detail-panel.ts +++ /dev/null @@ -1,192 +0,0 @@ -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 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
+