✨ feat(vscode): 「改动」tab 重做为整 PR 聚合的主从式内联 diff(git-diff-view)

- 左树右 diff:左栏全 PR 文件树 + 统计条(共改 N · +X−Y · 已查看 M/N),右栏选中单文件内联 diff
- 用 @git-diff-view/react 只渲染当前选中文件,绕开上万行全量渲染的性能坑
- gitea 新增 listPullRequestFiles、getPullRequest 取 merge_base/head,按需回两版原文
- diff 走 merge_base→head 三点口径(与 Gitea 网页一致),getRawFile 404 兜底成全增/全删/改名
- 已查看态按 head sha 落盘、新 push 自然重置;去掉提交列表、原生 diff 与 spx-gitea 虚拟文档 provider
This commit is contained in:
cruldra committed 2026-06-29 10:52:36 +08:00
1 parent ceea62ed16
commit fb505d59c5
18 files changed
+1149 -1127

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
/**
* 「改动」tab 的扩展侧 handler:实时从 Gitea 读整个 PR 的改动文件清单,并按需取
* 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。
*
* 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json`
* (键含 head sha,新 push 自然重置)。diff 左右两侧用 merge_base → head 的原文,
* 与 Gitea 网页 diff 口径一致;getRawFile 对 404 兜底空串,故新增/删除/改名天然成立。
*/
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote'
import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api'
import { readStateJsonComment } from '../../gitea/stateJson'
import { readConfirmed, writeConfirmed } from '../../sessions/prReviewStore'
/**
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined,
* 调用方据此走空态/错误分支。
*/
async function resolveRepoContext(panel: KanbanWebviewPanel): Promise<
{ host: string, owner: string, repo: string, token: string } | undefined
> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return undefined
const remote = await detectRepo(workspaceRoot)
if (!remote)
return undefined
const token = await getToken(panel.context, remote.host)
if (!token)
return undefined
return { host: remote.host, owner: remote.owner, repo: remote.repo, token }
}
/** 从工单的 state JSON 取 PR 号。无则返回 undefined(前端按空态处理,不算错误)。 */
async function resolvePrNumber(
ctx: { host: string, owner: string, repo: string, token: string },
issueNumber: number,
): Promise<number | undefined> {
const stateObj = await readStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber,
})
const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined
return pr ? Number(pr) : undefined
}
/** 由文件扩展名推断高亮语言 id(highlight.js/lowlight 口径);未知则回退扩展名本身。 */
function langFromPath(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
json: 'json',
py: 'python',
go: 'go',
rs: 'rust',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
rb: 'ruby',
php: 'php',
c: 'c',
h: 'c',
cpp: 'cpp',
cc: 'cpp',
hpp: 'cpp',
cs: 'csharp',
swift: 'swift',
md: 'markdown',
markdown: 'markdown',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
ini: 'ini',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
sql: 'sql',
css: 'css',
scss: 'scss',
less: 'less',
html: 'xml',
htm: 'xml',
xml: 'xml',
vue: 'xml',
svelte: 'xml',
}
return map[ext] ?? ext
}
/**
* 拉取工单对应 PR 的全部改动文件清单 + head/mergeBase ref + 已确认集合,推给 webview。
* 无 PR/缺上下文回空(前端按空态处理);真出错才带 error。
*/
export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
return
}
const index = await resolvePrNumber(ctx, issueNumber)
if (index === undefined) {
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
return
}
const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index })
// 三点 diff 的左侧 ref:优先 merge_base,缺失则退回 base sha。
const mergeBase = pr.mergeBase || pr.baseSha
const files = await listPullRequestFiles({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
index,
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmed = workspaceRoot
? await readConfirmed(workspaceRoot, issueNumber, pr.headSha)
: []
panel.postMessage({
type: 'pr-files/show',
issueNumber,
headSha: pr.headSha,
mergeBase,
files,
confirmed,
})
}
catch (err) {
panel.postMessage({
type: 'pr-files/show',
issueNumber,
headSha: '',
mergeBase: '',
files: [],
confirmed: [],
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 取单个文件改动前后的原文:左 = previousPath@mergeBase(无改名则 path),
* 右 = path@head。两侧原文 + 语言推断回给 webview,由 git-diff-view 内联渲染。
*/
export async function handleGetPrFileDiff(
panel: KanbanWebviewPanel,
args: { issueNumber: number, path: string, previousPath?: string },
): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' })
return
}
const index = await resolvePrNumber(ctx, args.issueNumber)
if (index === undefined) {
panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' })
return
}
const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index })
const mergeBase = pr.mergeBase || pr.baseSha
const oldPath = args.previousPath ?? args.path
const [oldContent, newContent] = await Promise.all([
getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }),
getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: args.path, ref: pr.headSha }),
])
panel.postMessage({
type: 'pr-file-diff/show',
issueNumber: args.issueNumber,
path: args.path,
oldContent,
newContent,
oldLang: langFromPath(oldPath),
newLang: langFromPath(args.path),
})
}
catch (err) {
panel.postMessage({
type: 'pr-file-diff/show',
issueNumber: args.issueNumber,
path: args.path,
oldContent: '',
newContent: '',
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 持久化某 PR 里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`,
* 键含 head sha。拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失
* 不该中断 UI。
*/
export async function handleSetPrReviewConfirmed(
panel: KanbanWebviewPanel,
args: { issueNumber: number, headSha: string, confirmed: string[] },
): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
await writeConfirmed(workspaceRoot, args.issueNumber, args.headSha, args.confirmed)
}
catch (err) {
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
}
}