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:
2026-06-29 10:52:36 +08:00
parent ceea62ed16
commit fb505d59c5
18 changed files with 1149 additions and 1127 deletions
+55 -92
View File
@@ -216,6 +216,12 @@ export interface GiteaPullRequest {
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
*/
body: string
/** head 分支最新提交 shaPR diff 的右侧 ref。 */
headSha: string
/** base 分支 shamergeBase 取不到时的左侧 ref 兜底。 */
baseSha: string
/** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */
mergeBase: string
}
/**
@@ -242,6 +248,9 @@ export async function getPullRequest(opts: {
merged_at?: unknown
html_url?: unknown
body?: unknown
head?: { sha?: unknown }
base?: { sha?: unknown }
merge_base?: unknown
}
return {
number: typeof data.number === 'number' ? data.number : Number(data.number),
@@ -250,6 +259,9 @@ export async function getPullRequest(opts: {
merged_at: typeof data.merged_at === 'string' ? data.merged_at : null,
html_url: typeof data.html_url === 'string' ? data.html_url : '',
body: typeof data.body === 'string' ? data.body : '',
headSha: typeof data.head?.sha === 'string' ? data.head.sha : '',
baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '',
mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '',
}
}
@@ -513,113 +525,64 @@ export async function deleteWebhook(opts: {
})
}
/** PR 提交的精简视图:列表渲染只需 sha / 首行消息 / 作者 / 时间。 */
export interface PrCommitDto {
sha: string
message: string
authorName: string
date: string
}
/** 单个提交内的一个文件改动:path 给 diff 用,status 决定徽标颜色。 */
export interface GitCommitFileDto {
/** PR 改动里的一个文件:path/status 给文件树,additions/deletions 求和成统计条,previousFilename 用于改名时取左侧旧路径。 */
export interface PrFileDto {
path: string
status: string
}
/** 一个提交的详情:parentSha 用作 diff 左侧 reffiles 是改动清单。 */
export interface GitCommitDetailDto {
sha: string
parentSha: string | undefined
files: GitCommitFileDto[]
additions: number
deletions: number
previousFilename?: string
}
/**
* 列出某 PR 的提交。关掉 files/stat/verification 让响应尽量小,列表只用到
* 顶层字段。message 取首行(完整消息留给 commit 详情页,这里只做概览)。
* 列出某 PR 的全部改动文件(三点 diffmerge_base → head,与 Gitea 网页 diff 口径一致)。
* 分页拉到短页为止;改名文件带 previousFilename(旧路径,取左侧内容用)。
*/
export async function listPullRequestCommits(opts: {
export async function listPullRequestFiles(opts: {
host: string
token: string
owner: string
repo: string
index: number
limit?: number
}): Promise<PrCommitDto[]> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/commits`,
)
url.searchParams.set('page', '1')
url.searchParams.set('limit', String(opts.limit ?? 50))
url.searchParams.set('files', 'false')
url.searchParams.set('stat', 'false')
url.searchParams.set('verification', 'false')
}): Promise<PrFileDto[]> {
const out: PrFileDto[] = []
let page = 1
// eslint-disable-next-line no-constant-condition
while (true) {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/files`,
)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const data = await res.json() as Array<{
sha?: unknown
created?: unknown
commit?: { message?: unknown, author?: { name?: unknown, date?: unknown } }
author?: { login?: unknown } | null
}>
if (!Array.isArray(data))
return []
const result = data.map((c) => {
const fullMessage = typeof c.commit?.message === 'string' ? c.commit.message : ''
const message = fullMessage.split('\n', 1)[0] ?? ''
const authorName = typeof c.commit?.author?.name === 'string' ? c.commit.author.name : ''
const commitDate = typeof c.commit?.author?.date === 'string' ? c.commit.author.date : ''
const created = typeof c.created === 'string' ? c.created : ''
return {
sha: typeof c.sha === 'string' ? c.sha : '',
message,
authorName,
date: commitDate || created,
}
})
// 按提交时间升序(最早在最前)。ISO 字符串字典序即时间序;date 为空者排前面。
result.sort((a, b) => a.date.localeCompare(b.date))
return result
}
/**
* 取单个提交详情,带文件清单。parentSha(首个父提交)作为 diff 左侧 ref;
* 根提交无 parent 时为 undefined,调用方据此把左侧当空内容处理(呈现全新增)。
*/
export async function getGitCommit(opts: {
host: string
token: string
owner: string
repo: string
sha: string
}): Promise<GitCommitDetailDto> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/git/commits/${opts.sha}`,
)
url.searchParams.set('files', 'true')
url.searchParams.set('stat', 'false')
url.searchParams.set('verification', 'false')
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const data = await res.json() as {
sha?: unknown
parents?: Array<{ sha?: unknown }>
files?: Array<{ filename?: unknown, status?: unknown }>
}
const parentSha = typeof data.parents?.[0]?.sha === 'string' ? data.parents[0].sha : undefined
const files = Array.isArray(data.files)
? data.files.map(f => ({
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as Array<{
filename?: unknown
previous_filename?: unknown
status?: unknown
additions?: unknown
deletions?: unknown
}>
if (!Array.isArray(batch) || batch.length === 0)
break
for (const f of batch) {
const prev = typeof f.previous_filename === 'string' && f.previous_filename.length > 0
? f.previous_filename
: undefined
out.push({
path: typeof f.filename === 'string' ? f.filename : '',
status: typeof f.status === 'string' ? f.status : '',
}))
: []
return {
sha: typeof data.sha === 'string' ? data.sha : opts.sha,
parentSha,
files,
additions: typeof f.additions === 'number' ? f.additions : 0,
deletions: typeof f.deletions === 'number' ? f.deletions : 0,
previousFilename: prev,
})
}
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**