✨ 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:
1 parent
ceea62ed16
commit
fb505d59c5
18 files changed
+1149
-1127
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user