✨ 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:
+55
-92
@@ -216,6 +216,12 @@ export interface GiteaPullRequest {
|
||||
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
|
||||
*/
|
||||
body: string
|
||||
/** head 分支最新提交 sha:PR diff 的右侧 ref。 */
|
||||
headSha: string
|
||||
/** base 分支 sha:mergeBase 取不到时的左侧 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 左侧 ref,files 是改动清单。 */
|
||||
export interface GitCommitDetailDto {
|
||||
sha: string
|
||||
parentSha: string | undefined
|
||||
files: GitCommitFileDto[]
|
||||
additions: number
|
||||
deletions: number
|
||||
previousFilename?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出某 PR 的提交。关掉 files/stat/verification 让响应尽量小,列表只用到
|
||||
* 顶层字段。message 取首行(完整消息留给 commit 详情页,这里只做概览)。
|
||||
* 列出某 PR 的全部改动文件(三点 diff,merge_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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ import { webhookCoordinator } from '../webhook/coordinator'
|
||||
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
||||
import * as issues from './handlers/issues'
|
||||
import * as managedSessions from './handlers/managedSessions'
|
||||
import * as prCommits from './handlers/prCommits'
|
||||
import * as prFiles from './handlers/prFiles'
|
||||
import * as sessions from './handlers/sessions'
|
||||
import * as settings from './handlers/settings'
|
||||
import * as terminals from './handlers/terminals'
|
||||
@@ -546,30 +546,20 @@ export class KanbanWebviewPanel {
|
||||
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-commits/get') {
|
||||
void prCommits.handleGetPrCommits(this, msg.issueNumber)
|
||||
if (msg.type === 'pr-files/get') {
|
||||
void prFiles.handleGetPrFiles(this, msg.issueNumber)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-commit-files/get') {
|
||||
void prCommits.handleGetPrCommitFiles(this, msg.issueNumber, msg.sha)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-commit-diff/open') {
|
||||
void prCommits.handleOpenPrCommitDiff(this, {
|
||||
if (msg.type === 'pr-file-diff/get') {
|
||||
void prFiles.handleGetPrFileDiff(this, {
|
||||
issueNumber: msg.issueNumber,
|
||||
sha: msg.sha,
|
||||
parentSha: msg.parentSha,
|
||||
path: msg.path,
|
||||
status: msg.status,
|
||||
previousPath: msg.previousPath,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-review/set') {
|
||||
void prCommits.handleSetPrReviewConfirmed(this, msg)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-review/set-commits') {
|
||||
void prCommits.handleSetPrReviewConfirmedCommits(this, msg)
|
||||
void prFiles.handleSetPrReviewConfirmed(this, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 「提交」tab 的扩展侧 handler:实时从 Gitea 读 PR 提交、提交内文件,并用
|
||||
* VS Code 原生 diff 比较改动前后。
|
||||
*
|
||||
* 数据全程实时拉取,不落任何 state JSON。文件内容通过一个
|
||||
* TextDocumentContentProvider(scheme `spx-gitea`)按需取 raw,使 diff 的两侧
|
||||
* 各自指向一个 ref 下的文件。
|
||||
*/
|
||||
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import { commands, Uri, window, workspace, type TextDocumentContentProvider } from 'vscode'
|
||||
import { getToken } from '../../auth/secrets'
|
||||
import { detectRepo } from '../../git/remote'
|
||||
import { getGitCommit, getRawFile, listPullRequestCommits } from '../../gitea/api'
|
||||
import { readStateJsonComment } from '../../gitea/stateJson'
|
||||
import { readConfirmed, readConfirmedCommits, writeConfirmed, writeConfirmedCommits } from '../../sessions/prReviewStore'
|
||||
|
||||
/** diff 文件内容走的虚拟文档 scheme。 */
|
||||
const SPX_GITEA_SCHEME = 'spx-gitea'
|
||||
|
||||
/** provider 只需注册一次;模块级守卫避免重复注册同一 scheme 抛错。 */
|
||||
let providerRegistered = false
|
||||
|
||||
/**
|
||||
* 解析当前工作区的 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取工单对应 PR 的提交列表并推给 webview。无 PR 时回空列表(前端按空态处理,
|
||||
* 不算错误);缺工作区/远程/token 同样回空列表。真出错才带 error。
|
||||
*/
|
||||
export async function handleGetPrCommits(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
try {
|
||||
const ctx = await resolveRepoContext(panel)
|
||||
if (!ctx) {
|
||||
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
if (!pr) {
|
||||
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const commits = await listPullRequestCommits({
|
||||
host: ctx.host,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
token: ctx.token,
|
||||
index: Number(pr),
|
||||
})
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const confirmedCommits = workspaceRoot
|
||||
? await readConfirmedCommits(workspaceRoot, issueNumber)
|
||||
: []
|
||||
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits, confirmedCommits })
|
||||
}
|
||||
catch (err) {
|
||||
panel.postMessage({
|
||||
type: 'pr-commits/show',
|
||||
issueNumber,
|
||||
commits: [],
|
||||
confirmedCommits: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某提交的文件清单并推给 webview。parentSha 一并回传,供前端发起 diff 时
|
||||
* 作为左侧 ref。
|
||||
*/
|
||||
export async function handleGetPrCommitFiles(
|
||||
panel: KanbanWebviewPanel,
|
||||
issueNumber: number,
|
||||
sha: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ctx = await resolveRepoContext(panel)
|
||||
if (!ctx) {
|
||||
panel.postMessage({ type: 'pr-commit-files/show', issueNumber, sha, files: [], confirmed: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const detail = await getGitCommit({
|
||||
host: ctx.host,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
token: ctx.token,
|
||||
sha,
|
||||
})
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const confirmed = workspaceRoot
|
||||
? await readConfirmed(workspaceRoot, issueNumber, sha)
|
||||
: []
|
||||
panel.postMessage({
|
||||
type: 'pr-commit-files/show',
|
||||
issueNumber,
|
||||
sha,
|
||||
parentSha: detail.parentSha,
|
||||
files: detail.files,
|
||||
confirmed,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
panel.postMessage({
|
||||
type: 'pr-commit-files/show',
|
||||
issueNumber,
|
||||
sha,
|
||||
files: [],
|
||||
confirmed: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化某提交里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`。
|
||||
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
|
||||
*/
|
||||
export async function handleSetPrReviewConfirmed(
|
||||
panel: KanbanWebviewPanel,
|
||||
args: { issueNumber: number, sha: string, confirmed: string[] },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot)
|
||||
return
|
||||
await writeConfirmed(workspaceRoot, args.issueNumber, args.sha, args.confirmed)
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化某工单已确认(已审阅)的提交 sha 集合到 `.spx/pr-review-confirmed.json`。
|
||||
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
|
||||
*/
|
||||
export async function handleSetPrReviewConfirmedCommits(
|
||||
panel: KanbanWebviewPanel,
|
||||
args: { issueNumber: number, confirmed: string[] },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot)
|
||||
return
|
||||
await writeConfirmedCommits(workspaceRoot, args.issueNumber, args.confirmed)
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('handleSetPrReviewConfirmedCommits 写盘失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒注册 raw 内容 provider(只一次)。闭包捕获 panel 以便取 token——diff 两侧的
|
||||
* 文档内容都经此 provider 按 uri 里的 ref 拉取。
|
||||
*/
|
||||
function ensureContentProvider(panel: KanbanWebviewPanel): void {
|
||||
if (providerRegistered)
|
||||
return
|
||||
providerRegistered = true
|
||||
|
||||
const provider: TextDocumentContentProvider = {
|
||||
async provideTextDocumentContent(uri: Uri): Promise<string> {
|
||||
const params = new URLSearchParams(uri.query)
|
||||
const host = params.get('host') ?? ''
|
||||
const owner = params.get('owner') ?? ''
|
||||
const repo = params.get('repo') ?? ''
|
||||
const ref = params.get('ref') ?? ''
|
||||
// uri.path 形如 `/src/foo.ts`:去掉前导斜杠并 decode 还原原始路径。
|
||||
const filepath = decodeURIComponent(uri.path.replace(/^\//, ''))
|
||||
if (!host || !owner || !repo)
|
||||
return ''
|
||||
const token = await getToken(panel.context, host)
|
||||
if (!token)
|
||||
return ''
|
||||
return getRawFile({ host, owner, repo, token, filepath, ref })
|
||||
},
|
||||
}
|
||||
|
||||
const disposable = workspace.registerTextDocumentContentProvider(SPX_GITEA_SCHEME, provider)
|
||||
panel.context.subscriptions.push(disposable)
|
||||
}
|
||||
|
||||
/** 把改动文件的一侧(某 ref 下内容)编码成 spx-gitea 虚拟文档 uri。 */
|
||||
function buildSideUri(opts: {
|
||||
host: string
|
||||
owner: string
|
||||
repo: string
|
||||
ref: string
|
||||
path: string
|
||||
}): Uri {
|
||||
const query = new URLSearchParams({
|
||||
host: opts.host,
|
||||
owner: opts.owner,
|
||||
repo: opts.repo,
|
||||
ref: opts.ref,
|
||||
}).toString()
|
||||
return Uri.from({ scheme: SPX_GITEA_SCHEME, path: `/${opts.path}`, query })
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 VS Code 原生 diff 比较一个文件在 parentSha → sha 之间的改动。
|
||||
*
|
||||
* 左侧 = parentSha 下内容,右侧 = sha 下内容。根提交无 parentSha 时左侧 ref 给
|
||||
* 空串,raw 取不到(404)→ 空内容,自然呈现全新增。删除文件右侧在 sha 下 404
|
||||
* → 空,呈现删除。
|
||||
*/
|
||||
export async function handleOpenPrCommitDiff(
|
||||
panel: KanbanWebviewPanel,
|
||||
args: { issueNumber: number, sha: string, parentSha?: string, path: string, status: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ctx = await resolveRepoContext(panel)
|
||||
if (!ctx) {
|
||||
void window.showWarningMessage('当前工作区没有 Gitea 仓库或未配置 token')
|
||||
return
|
||||
}
|
||||
ensureContentProvider(panel)
|
||||
|
||||
const leftUri = buildSideUri({
|
||||
host: ctx.host,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
ref: args.parentSha ?? '',
|
||||
path: args.path,
|
||||
})
|
||||
const rightUri = buildSideUri({
|
||||
host: ctx.host,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
ref: args.sha,
|
||||
path: args.path,
|
||||
})
|
||||
const title = `${args.path} (${(args.parentSha ?? '∅').slice(0, 7)} ↔ ${args.sha.slice(0, 7)})`
|
||||
await commands.executeCommand('vscode.diff', leftUri, rightUri, title)
|
||||
}
|
||||
catch (err) {
|
||||
void window.showErrorMessage(`打开 diff 失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -19,18 +19,16 @@ export interface ManagedSessionsShowData {
|
||||
sessions: ManagedSessionShowItem[]
|
||||
}
|
||||
|
||||
/** PR 提交的精简视图(webview 列表渲染用)。 */
|
||||
export interface PrCommit {
|
||||
sha: string
|
||||
message: string
|
||||
authorName: string
|
||||
date: string
|
||||
}
|
||||
|
||||
/** 提交内单个文件改动:status 取 added/modified/deleted/renamed/copied 等。 */
|
||||
export interface PrCommitFile {
|
||||
/**
|
||||
* PR 改动里的一个文件:status 取 added/modified/deleted/renamed/copied 等,
|
||||
* additions/deletions 求和成统计条,previousFilename 在改名时给出旧路径。
|
||||
*/
|
||||
export interface PrFile {
|
||||
path: string
|
||||
status: string
|
||||
additions: number
|
||||
deletions: number
|
||||
previousFilename?: string
|
||||
}
|
||||
|
||||
export interface ToastLink {
|
||||
@@ -102,8 +100,8 @@ export type ExtensionToWebview
|
||||
| { type: 'issue/remove', issueNumber: number }
|
||||
| { type: 'profiles/show', data: ProfilesData }
|
||||
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
|
||||
| { type: 'pr-commits/show', issueNumber: number, commits: PrCommit[], confirmedCommits: string[], error?: string }
|
||||
| { type: 'pr-commit-files/show', issueNumber: number, sha: string, parentSha?: string, files: PrCommitFile[], confirmed: string[], error?: string }
|
||||
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], error?: string }
|
||||
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
|
||||
|
||||
export type WebviewToExtension
|
||||
= | { type: 'issues/refresh' }
|
||||
@@ -174,8 +172,6 @@ export type WebviewToExtension
|
||||
| { type: 'managed-sessions/resume', sessionId: string }
|
||||
| { type: 'managed-sessions/delete', sessionId: string }
|
||||
| { type: 'managed-sessions/close-tab', sessionId: string }
|
||||
| { type: 'pr-commits/get', issueNumber: number }
|
||||
| { type: 'pr-commit-files/get', issueNumber: number, sha: string }
|
||||
| { type: 'pr-commit-diff/open', issueNumber: number, sha: string, parentSha?: string, path: string, status: string }
|
||||
| { type: 'pr-review/set', issueNumber: number, sha: string, confirmed: string[] }
|
||||
| { type: 'pr-review/set-commits', issueNumber: number, confirmed: string[] }
|
||||
| { type: 'pr-files/get', issueNumber: number }
|
||||
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
||||
| { type: 'pr-review/set', issueNumber: number, headSha: string, confirmed: string[] }
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* Workspace 级「PR 审查已确认文件」持久化。
|
||||
*
|
||||
* 落盘到 `<workspace>/.spx/pr-review-confirmed.json`,形状
|
||||
* `Record<string, string[]>`:key = `${issueNumber}:${sha}`,value = 该提交里
|
||||
* 已确认(已审阅)的文件完整路径数组。目录不单独记录——目录是否确认靠「其下
|
||||
* 可见文件是否全已确认」推导。文件不存在或解析失败时按空处理。
|
||||
* `Record<string, string[]>`:key = `${issueNumber}:${sha}`(sha 取 PR 的 head
|
||||
* sha,故新 push 换 key、已查看进度自然重置),value = 该 PR 里已确认(已审阅)
|
||||
* 的文件完整路径数组。目录不单独记录——目录是否确认靠「其下可见文件是否全已
|
||||
* 确认」推导。文件不存在或解析失败时按空处理。
|
||||
*/
|
||||
|
||||
import { promises as fsp } from 'node:fs'
|
||||
@@ -79,36 +80,3 @@ export async function writeConfirmed(
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取某工单已确认(已审阅)的提交 sha 数组。key = `${issueNumber}:commits`
|
||||
* (`commits` 非合法 sha,不与文件确认的 `${issueNumber}:${sha}` key 冲突)。
|
||||
* 文件缺失、解析失败或无该 key 时返回 `[]`。
|
||||
*/
|
||||
export async function readConfirmedCommits(
|
||||
workspaceRoot: string,
|
||||
issueNumber: number,
|
||||
): Promise<string[]> {
|
||||
const map = await readMap(workspaceRoot)
|
||||
return map[`${issueNumber}:commits`] ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入某工单已确认的提交 sha 数组。空数组则删除该 key;非空则设值。父目录不存在
|
||||
* 会自动创建。
|
||||
*/
|
||||
export async function writeConfirmedCommits(
|
||||
workspaceRoot: string,
|
||||
issueNumber: number,
|
||||
shas: string[],
|
||||
): Promise<void> {
|
||||
const map = await readMap(workspaceRoot)
|
||||
const key = `${issueNumber}:commits`
|
||||
if (shas.length > 0)
|
||||
map[key] = shas
|
||||
else
|
||||
delete map[key]
|
||||
const dir = path.join(workspaceRoot, '.spx')
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user