diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index 4b410ac..bf5879e 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -58,6 +58,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.6) + '@git-diff-view/file': + specifier: ^0.1.6 + version: 0.1.6 + '@git-diff-view/react': + specifier: ^0.1.6 + version: 0.1.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -715,6 +721,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@git-diff-view/core@0.1.6': + resolution: {integrity: sha512-q2Ch8jURF6pL7VeNpOgHBRVY9gsGLXCOYpKXHG3BqpXe0kv6GNSUux8SmAYsDrakBzfgDClODxDtsM2rfiWpnA==} + + '@git-diff-view/file@0.1.6': + resolution: {integrity: sha512-VSsByONBl98c4SVyoN8I1twooEZCh63AbH79tcpvCAzt7nJ5Ulmr1UIS8qAaMDDZiEgXq13JlkZkQh9vpc6xZQ==} + + '@git-diff-view/lowlight@0.1.6': + resolution: {integrity: sha512-YIsiAc2aWAePWaDNi3k8xI0Vs/ZItt5J6nrftTIFbMFN3GwDOsyJFm2L7o8XWKTJkV2yItaz28KUI9CWj0MVZA==} + + '@git-diff-view/react@0.1.6': + resolution: {integrity: sha512-koABBon5bNKh6/WnWSxggK9ojw+cvWAPnY2/ciOkwlR+8dm0h6A7Qa5kP2HFDxqYHwZ2imkGMcSLgXMOnWHRFA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1183,6 +1204,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1387,9 +1411,15 @@ packages: '@vue/compiler-ssr@3.4.27': resolution: {integrity: sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==} + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + '@vue/shared@3.4.27': resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==} + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1726,6 +1756,10 @@ packages: resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2032,6 +2066,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2208,6 +2245,10 @@ packages: resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} engines: {node: '>= 0.4'} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -2510,6 +2551,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2955,6 +2999,11 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + reactivity-store@0.4.0: + resolution: {integrity: sha512-uL9uoREOBg2o4zUa8vMU0AbvAOk0osPloizscmyZqMvJzcuuKX3ELFYYr1DX8gAcfvlhPduz4QuLZn1eChCu4Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + read-pkg@9.0.1: resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} engines: {node: '>=18'} @@ -3339,6 +3388,11 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4067,6 +4121,39 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@git-diff-view/core@0.1.6': + dependencies: + '@git-diff-view/lowlight': 0.1.6 + fast-diff: 1.3.0 + highlight.js: 11.11.1 + lowlight: 3.3.0 + + '@git-diff-view/file@0.1.6': + dependencies: + '@git-diff-view/core': 0.1.6 + diff: 8.0.4 + fast-diff: 1.3.0 + highlight.js: 11.11.1 + lowlight: 3.3.0 + + '@git-diff-view/lowlight@0.1.6': + dependencies: + '@types/hast': 3.0.4 + highlight.js: 11.11.1 + lowlight: 3.3.0 + + '@git-diff-view/react@0.1.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@git-diff-view/core': 0.1.6 + '@types/hast': 3.0.4 + fast-diff: 1.3.0 + highlight.js: 11.11.1 + lowlight: 3.3.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + reactivity-store: 0.4.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -4494,6 +4581,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -4780,8 +4871,14 @@ snapshots: '@vue/compiler-dom': 3.4.27 '@vue/shared': 3.4.27 + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + '@vue/shared@3.4.27': {} + '@vue/shared@3.5.39': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -5113,6 +5210,8 @@ snapshots: diff-sequences@27.5.1: {} + diff@8.0.4: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5532,6 +5631,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5699,6 +5800,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@11.11.1: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -5974,6 +6077,12 @@ snapshots: longest-streak@3.1.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@10.4.3: {} lru-cache@11.0.0: {} @@ -6621,6 +6730,13 @@ snapshots: react@19.2.6: {} + reactivity-store@0.4.0(react@19.2.6): + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + read-pkg@9.0.1: dependencies: '@types/normalize-package-data': 2.4.4 @@ -7022,6 +7138,10 @@ snapshots: url-join@4.0.1: {} + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + util-deprecate@1.0.2: {} uuid@8.3.2: {} diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 7fc1d45..e987e58 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -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 { - 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 { + 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 { - 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 } /** diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 8dcba2e..9fb7c93 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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 } } diff --git a/vscode/src/panel/handlers/prCommits.ts b/vscode/src/panel/handlers/prCommits.ts deleted file mode 100644 index e802b44..0000000 --- a/vscode/src/panel/handlers/prCommits.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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)}`) - } -} diff --git a/vscode/src/panel/handlers/prFiles.ts b/vscode/src/panel/handlers/prFiles.ts new file mode 100644 index 0000000..3aeb5d0 --- /dev/null +++ b/vscode/src/panel/handlers/prFiles.ts @@ -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 { + 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 = { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index eb050f7..865abf3 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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[] } diff --git a/vscode/src/sessions/prReviewStore.ts b/vscode/src/sessions/prReviewStore.ts index 14a1a9e..7c2bec8 100644 --- a/vscode/src/sessions/prReviewStore.ts +++ b/vscode/src/sessions/prReviewStore.ts @@ -2,9 +2,10 @@ * Workspace 级「PR 审查已确认文件」持久化。 * * 落盘到 `/.spx/pr-review-confirmed.json`,形状 - * `Record`:key = `${issueNumber}:${sha}`,value = 该提交里 - * 已确认(已审阅)的文件完整路径数组。目录不单独记录——目录是否确认靠「其下 - * 可见文件是否全已确认」推导。文件不存在或解析失败时按空处理。 + * `Record`: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 { - const map = await readMap(workspaceRoot) - return map[`${issueNumber}:commits`] ?? [] -} - -/** - * 写入某工单已确认的提交 sha 数组。空数组则删除该 key;非空则设值。父目录不存在 - * 会自动创建。 - */ -export async function writeConfirmedCommits( - workspaceRoot: string, - issueNumber: number, - shas: string[], -): Promise { - 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') -} diff --git a/vscode/webview-ui/package.json b/vscode/webview-ui/package.json index ad9e57c..202f0d1 100644 --- a/vscode/webview-ui/package.json +++ b/vscode/webview-ui/package.json @@ -13,6 +13,8 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@git-diff-view/file": "^0.1.6", + "@git-diff-view/react": "^0.1.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index f94db50..9cdc4d7 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -10,14 +10,14 @@ import type { ClaudeProfile } from '../hooks/useIssues' import type { ProfilesData } from '../lib/messages' import type { Issue, ManagedSessionsData } from '../types' -import { ClipboardList, GitCommitHorizontal, Layers, MessagesSquare } from 'lucide-react' +import { ClipboardList, FileDiff, Layers, MessagesSquare } from 'lucide-react' import { useState } from 'react' import { IssueDetailPanel } from './IssueDetailPanel' import { ManagedSessionsPanel } from './ManagedSessionsPanel' -import { PrCommitsPanel } from './PrCommitsPanel' +import { PrFilesPanel } from './PrFilesPanel' import { ProfileGrid } from './ProfileGrid' -type TabKey = 'issue' | 'profile' | 'sessions' | 'commits' +type TabKey = 'issue' | 'profile' | 'sessions' | 'changes' interface BottomTabsProps { // Issue tab props (pass-through to IssueDetailPanel) @@ -87,10 +87,10 @@ export function BottomTabs(props: BottomTabsProps) { title="会话" /> setTab('commits')} - icon={} - title="提交" + active={tab === 'changes'} + onClick={() => setTab('changes')} + icon={} + title="改动" /> @@ -143,8 +143,8 @@ export function BottomTabs(props: BottomTabsProps) { onCloseTab={props.onManagedSessionCloseTab} /> )} - {tab === 'commits' && ( - + {tab === 'changes' && ( + )} diff --git a/vscode/webview-ui/src/components/PrCommitsPanel.tsx b/vscode/webview-ui/src/components/PrCommitsPanel.tsx deleted file mode 100644 index 3a161cd..0000000 --- a/vscode/webview-ui/src/components/PrCommitsPanel.tsx +++ /dev/null @@ -1,533 +0,0 @@ -/** - * 「提交」tab(底部第四个):浏览当前工单 PR 的提交 → 提交内文件 → 原生 diff。 - * - * - 上半区:提交列表(sha 前 7 位 + 消息首行 + 作者 + 时间),单选高亮。 - * - 下半区:选中提交的文件清单,渲染成目录树(合并单子目录链),文件行状态徽标 - * (A/M/D/R 着色)+ 文件名,点击触发 `vscode.diff` 比较该文件在 parentSha → sha - * 之间的改动;`tests/` 目录下的文件不展示。 - * - * 自带 usePrCommits hook,直接经 lib/vscode 收发消息,不依赖上层回调。 - */ - -import type { KeyboardEvent as ReactKeyboardEvent } from 'react' -import type { Issue } from '../types' -import { Check, ChevronDown, ChevronRight, Folder } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { usePrCommits } from '../hooks/usePrCommits' - -interface PrCommitsPanelProps { - issue: Issue | null -} - -/** 提交时间格式化,与 ManagedSessionsPanel 风格一致;无效值回空串。 */ -function formatDate(iso: string): string { - if (!iso) - return '' - const d = new Date(iso) - if (Number.isNaN(d.getTime())) - return '' - return d.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) -} - -/** 状态首字母徽标(大写),未知状态取首字母兜底。 */ -function statusBadge(status: string): string { - const s = status.toLowerCase() - if (s.startsWith('add')) - return 'A' - if (s.startsWith('modif')) - return 'M' - if (s.startsWith('delet') || s.startsWith('remov')) - return 'D' - if (s.startsWith('renam')) - return 'R' - if (s.startsWith('cop')) - return 'C' - return (status[0] ?? '?').toUpperCase() -} - -/** 徽标颜色用 VS Code git 装饰主题变量,与编辑器源码管理视图一致。 */ -function statusColorVar(status: string): string { - const s = status.toLowerCase() - if (s.startsWith('add') || s.startsWith('cop')) - return 'var(--vscode-gitDecoration-addedResourceForeground)' - if (s.startsWith('delet') || s.startsWith('remov')) - return 'var(--vscode-gitDecoration-deletedResourceForeground)' - if (s.startsWith('renam')) - return 'var(--vscode-gitDecoration-renamedResourceForeground, var(--vscode-gitDecoration-modifiedResourceForeground))' - return 'var(--vscode-gitDecoration-modifiedResourceForeground)' -} - -/** PR diff 里跳过测试代码:路径任一段为 `tests` 即视为忽略(匹配 `tests/…` 或 `…/tests/…`)。 */ -function isIgnoredPath(path: string): boolean { - return path.split('/').some(seg => seg === 'tests') -} - -/** - * 拍平后的可渲染行: - * - 目录行带层级、(合并单子目录链后的)显示名、完整路径(折叠状态的唯一 key)、 - * 当前是否折叠,以及其子树下所有可见文件的完整路径(用于目录级「已确认」级联); - * - 文件行额外带完整路径与状态。 - */ -type TreeRow - = | { kind: 'dir', name: string, depth: number, path: string, collapsed: boolean, descendantFiles: string[] } - | { kind: 'file', name: string, depth: number, path: string, status: string } - -interface FileLeaf { name: string, path: string, status: string } - -/** 构树用的中间目录节点:子目录按名索引,文件平铺。 */ -interface DirNode { - dirs: Map - files: FileLeaf[] -} - -function newDirNode(): DirNode { - return { dirs: new Map(), files: [] } -} - -/** - * 把 `{ path, status }[]` 构建成嵌套目录树(只构树,不拍平、不合并)。 - * - * 路径按 `/` 切分,最后一段是文件、其余是目录层级;折叠的拍平交给 `flattenTree`。 - */ -function buildDirTree(files: ReadonlyArray<{ path: string, status: string }>): DirNode { - const root = newDirNode() - for (const f of files) { - const segs = f.path.split('/') - const fileName = segs[segs.length - 1] - let node = root - for (let i = 0; i < segs.length - 1; i++) { - const seg = segs[i] - let child = node.dirs.get(seg) - if (!child) { - child = newDirNode() - node.dirs.set(seg, child) - } - node = child - } - node.files.push({ name: fileName, path: f.path, status: f.status }) - } - return root -} - -/** - * DFS 拍平目录树成可渲染行列表,并感知折叠状态。 - * - * - 合并单子目录链(VS Code "compact folders" 风格):目录只有 1 个子目录且无文件时, - * 两级目录名用 `/` 连接成一行,递归直到分叉或遇到文件;同时累计该目录行的完整路径 - * (从根拼起、含所有被合并的段),作为折叠状态的唯一 key。 - * - 每层先目录后文件,各自按 `localeCompare` 升序,DFS 顺序输出,`depth` 从 0 起。 - * - 目录被折叠(`collapsed` 含其完整路径)时,只 push 目录行本身,不再递归其子节点。 - */ -/** 收集一个目录子树下所有文件的完整路径(含各级子目录),用于目录级确认级联。 */ -function collectDescendantFiles(node: DirNode): string[] { - const out: string[] = [] - const walk = (n: DirNode) => { - for (const f of n.files) - out.push(f.path) - for (const child of n.dirs.values()) - walk(child) - } - walk(node) - return out -} - -function flattenTree(root: DirNode, collapsed: ReadonlySet): TreeRow[] { - const rows: TreeRow[] = [] - // prefix 是父目录的完整路径(含尾部 `/`),用于拼出当前目录行的折叠 key。 - const emit = (node: DirNode, depth: number, prefix: string) => { - const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b)) - for (const name of dirNames) { - // 合并单子目录链:沿途只要恰好 1 个子目录且本级无文件,就把目录名拼起来; - // path 同步累计每个被合并的段,作为折叠状态的唯一标识。 - let label = name - let path = prefix + name - let cur = node.dirs.get(name)! - while (cur.files.length === 0 && cur.dirs.size === 1) { - const [childName, childNode] = [...cur.dirs.entries()][0] - label += `/${childName}` - path += `/${childName}` - cur = childNode - } - const isCollapsed = collapsed.has(path) - rows.push({ - kind: 'dir', - name: label, - depth, - path, - collapsed: isCollapsed, - descendantFiles: collectDescendantFiles(cur), - }) - // 折叠则隐藏其下所有内容,不再递归。 - if (!isCollapsed) - emit(cur, depth + 1, `${path}/`) - } - const sortedFiles = [...node.files].sort((a, b) => a.name.localeCompare(b.name)) - for (const f of sortedFiles) - rows.push({ kind: 'file', name: f.name, depth, path: f.path, status: f.status }) - } - emit(root, 0, '') - return rows -} - -export function PrCommitsPanel({ issue }: PrCommitsPanelProps) { - const issueNumber = issue?.number - const { - commits, - commitsError, - loadingCommits, - filesBySha, - parentShaBySha, - filesErrorBySha, - confirmedBySha, - confirmedCommits, - getFiles, - openDiff, - setConfirmed, - markCommitsConfirmed, - } = usePrCommits(issueNumber) - - const [selectedSha, setSelectedSha] = useState(undefined) - // 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。 - const [collapsed, setCollapsed] = useState>(new Set()) - // JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover), - // Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。 - const [hoveredPath, setHoveredPath] = useState(null) - // 提交行的悬停态(与文件行的 hoveredPath 分开),同样 JS 驱动确认按钮显隐。 - const [hoveredCommit, setHoveredCommit] = useState(null) - // 提交列表
    ,用于方向键切换后把选中项滚进可视区。 - const commitListRef = useRef(null) - - // 切工单时清掉选中态(hook 已清缓存,这里只管 UI 选中)。 - useEffect(() => { - setSelectedSha(undefined) - }, [issueNumber]) - - // 切提交后文件树默认全展开,避免沿用上一提交的折叠状态。 - useEffect(() => { - setCollapsed(new Set()) - }, [selectedSha]) - - // 选中提交且尚无文件缓存 → 拉一次文件清单。 - useEffect(() => { - if (selectedSha && !(selectedSha in filesBySha)) - getFiles(selectedSha) - }, [selectedSha, filesBySha, getFiles]) - - // 方向键切换选中后,把选中的提交行滚进可视区(按 dataset 匹配,避免 sha 进选择器)。 - useEffect(() => { - if (!selectedSha) - return - const ul = commitListRef.current - if (!ul) - return - for (const li of ul.children) { - if ((li as HTMLElement).dataset.sha === selectedSha) { - (li as HTMLElement).scrollIntoView({ block: 'nearest' }) - break - } - } - }, [selectedSha]) - - const selectedFiles = selectedSha ? filesBySha[selectedSha] : undefined - const selectedFilesError = selectedSha ? filesErrorBySha[selectedSha] : undefined - const selectedParentSha = selectedSha ? parentShaBySha[selectedSha] : undefined - - // 过滤掉 tests/ 后构嵌套树;selectedFiles 还没加载(undefined)时给空列表。 - // 构树只依赖文件本身,折叠状态变化不必重构树,只重新拍平。 - const dirTree = useMemo( - () => buildDirTree((selectedFiles ?? []).filter(f => !isIgnoredPath(f.path))), - [selectedFiles], - ) - const fileTreeRows = useMemo(() => flattenTree(dirTree, collapsed), [dirTree, collapsed]) - - // 当前提交已确认的文件路径集合(目录确认靠「其下文件全在此集合」推导)。 - const confirmed = useMemo( - () => new Set(confirmedBySha[selectedSha ?? ''] ?? []), - [confirmedBySha, selectedSha], - ) - - // 当前工单已确认的提交 sha 集合(提交行勾标记 + 行淡化用)。 - const confirmedCommitSet = useMemo(() => new Set(confirmedCommits), [confirmedCommits]) - - // 所有 hook 必须在任何 early return 之前调用完。否则工单空/有无 PR 之间切换时 - // hook 数量变化,触发 React #300(表现为面板空白 / 渲染出错)。 - if (!issue) { - return ( -
    - 未选中工单 -
    - ) - } - - if (!issue.pr) { - return ( -
    - 该工单还没有 PR -
    - ) - } - - // 提交列表方向键切换:未选中时 ArrowDown 选首、ArrowUp 选末;已选中则按下标夹取边界移动。 - const onCommitListKeyDown = (e: ReactKeyboardEvent) => { - if (commits.length === 0) - return - const cur = selectedSha ? commits.findIndex(c => c.sha === selectedSha) : -1 - let next: number | undefined - if (e.key === 'ArrowDown') - next = cur < 0 ? 0 : Math.min(cur + 1, commits.length - 1) - else if (e.key === 'ArrowUp') - next = cur < 0 ? commits.length - 1 : Math.max(cur - 1, 0) - else if (e.key === 'Home') - next = 0 - else if (e.key === 'End') - next = commits.length - 1 - if (next === undefined) - return - e.preventDefault() - setSelectedSha(commits[next].sha) - } - - return ( -
    - {/* 上半区:提交列表 */} -
    -
    - {`提交 #${issue.pr}`} -
    -
    - {commitsError - ? ( -
    - {commitsError} -
    - ) - : loadingCommits - ? ( -
    - 加载中… -
    - ) - : commits.length === 0 - ? ( -
    - 该 PR 暂无提交 -
    - ) - : ( -
      - {commits.map((c) => { - const isConfirmed = confirmedCommitSet.has(c.sha) - return ( -
    • setHoveredCommit(c.sha)} - onMouseLeave={() => setHoveredCommit(p => (p === c.sha ? null : p))} - onClick={() => { - setSelectedSha(c.sha) - // 选中后把焦点给列表,方向键即时可用(不被看板抢走)。 - commitListRef.current?.focus() - }} - className={`flex cursor-pointer flex-col gap-0.5 border-b border-[var(--vscode-panel-border)] px-2 py-1.5 ${ - selectedSha === c.sha - ? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]' - : 'hover:bg-[var(--vscode-list-hoverBackground)]' - } ${isConfirmed ? 'opacity-60' : ''}`} - > -
      - - {c.sha.slice(0, 7)} - - - {c.message} - - -
      -
      - {[c.authorName, formatDate(c.date)].filter(Boolean).join(' · ')} -
      -
    • - ) - })} -
    - )} -
    -
    - - {/* 下半区:选中提交的文件清单 */} -
    -
    - 文件 -
    -
    - {!selectedSha - ? ( -
    - 选择一个提交查看文件 -
    - ) - : selectedFilesError - ? ( -
    - {selectedFilesError} -
    - ) - : selectedFiles === undefined - ? ( -
    - 加载中… -
    - ) - : selectedFiles.length === 0 - ? ( -
    - 该提交无文件改动 -
    - ) - // selectedFiles 非空但全被 tests/ 过滤掉,行列表才会为空。 - : fileTreeRows.length === 0 - ? ( -
    - 该提交改动均在 tests/ 目录(已忽略) -
    - ) - : ( -
      - {fileTreeRows.map((row) => { - const indentStyle = { paddingLeft: row.depth * 12 + 8 } - if (row.kind === 'dir') { - // 目录确认 = 其下所有可见文件都已确认(无可见文件则视为未确认)。 - const isConfirmed - = row.descendantFiles.length > 0 - && row.descendantFiles.every(p => confirmed.has(p)) - return ( - // 目录行:点击切换折叠态;左侧箭头随折叠态翻转,path 作为唯一 key。 -
    • setHoveredPath(row.path)} - onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))} - onClick={() => setCollapsed((prev) => { - const n = new Set(prev) - n.has(row.path) ? n.delete(row.path) : n.add(row.path) - return n - })} - className={`group flex cursor-pointer items-center gap-1.5 py-0.5 pr-2 text-xs text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] ${ - isConfirmed ? 'opacity-60' : '' - }`} - style={indentStyle} - > - {row.collapsed - ? - : } - - {row.name} - -
    • - ) - } - const isConfirmed = confirmed.has(row.path) - return ( - // 文件行:点击触发 diff,title 用完整路径。 -
    • setHoveredPath(row.path)} - onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))} - onClick={() => openDiff(selectedSha, selectedParentSha, row.path, row.status)} - title={`${row.status} · ${row.path}`} - className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 hover:bg-[var(--vscode-list-hoverBackground)] ${ - isConfirmed ? 'opacity-60' : '' - }`} - style={indentStyle} - > - - {statusBadge(row.status)} - - {row.name} - -
    • - ) - })} -
    - )} -
    -
    -
    - ) -} diff --git a/vscode/webview-ui/src/components/PrFilesPanel.tsx b/vscode/webview-ui/src/components/PrFilesPanel.tsx new file mode 100644 index 0000000..5d28ada --- /dev/null +++ b/vscode/webview-ui/src/components/PrFilesPanel.tsx @@ -0,0 +1,157 @@ +/** + * 「改动」tab(底部第四个):整个 PR 的改动以主从式呈现。 + * + * - 左栏:统计条(共改 N 文件 · +X −Y · 已查看 M/N)+ 全 PR 文件树(目录树、状态徽标、 + * tests/ 过滤、已查看勾)。 + * - 右栏:选中文件的内联 diff(git-diff-view,只渲染当前一个文件)。 + * + * 自带 usePrFiles hook,直接经 lib/vscode 收发消息,不依赖上层回调。diff 左右两侧用 + * merge_base → head 的原文(与 Gitea 网页 diff 口径一致),已查看态按 head sha 落盘。 + */ + +import type { Issue } from '../types' +import { useEffect, useMemo, useState } from 'react' +import { usePrFiles } from '../hooks/usePrFiles' +import { FileDiffPane } from './prFiles/FileDiffPane' +import { FileTree } from './prFiles/FileTree' +import { buildDirTree, flattenTree, isIgnoredPath } from './prFiles/fileTree' + +interface PrFilesPanelProps { + issue: Issue | null +} + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ) +} + +export function PrFilesPanel({ issue }: PrFilesPanelProps) { + const issueNumber = issue?.number + const { + files, + filesError, + loading, + confirmed, + diffByPath, + getFileDiff, + setConfirmed, + } = usePrFiles(issueNumber) + + const [selectedPath, setSelectedPath] = useState(undefined) + // 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。 + const [collapsed, setCollapsed] = useState>(new Set()) + + // 切工单时清掉选中态与折叠态(hook 已清缓存)。 + useEffect(() => { + setSelectedPath(undefined) + setCollapsed(new Set()) + }, [issueNumber]) + + // 过滤掉 tests/ 后的可见文件;构树只依赖文件本身,折叠态变化只重新拍平。 + const visibleFiles = useMemo(() => files.filter(f => !isIgnoredPath(f.path)), [files]) + const dirTree = useMemo( + () => buildDirTree(visibleFiles.map(f => ({ path: f.path, status: f.status }))), + [visibleFiles], + ) + const rows = useMemo(() => flattenTree(dirTree, collapsed), [dirTree, collapsed]) + + const confirmedSet = useMemo(() => new Set(confirmed), [confirmed]) + // 路径 → 文件,选中时取 status/previousFilename。 + const fileByPath = useMemo(() => new Map(visibleFiles.map(f => [f.path, f])), [visibleFiles]) + + const stats = useMemo(() => { + let additions = 0 + let deletions = 0 + for (const f of visibleFiles) { + additions += f.additions + deletions += f.deletions + } + const confirmedCount = visibleFiles.filter(f => confirmedSet.has(f.path)).length + return { total: visibleFiles.length, additions, deletions, confirmedCount } + }, [visibleFiles, confirmedSet]) + + const selectedFile = selectedPath ? fileByPath.get(selectedPath) : undefined + + // 所有 hook 必须在任何 early return 之前调用完,否则工单有无 PR 之间切换时 + // hook 数量变化会触发 React #300(表现为面板空白 / 渲染出错)。 + if (!issue) + return 未选中工单 + if (!issue.pr) + return 该工单还没有 PR + + const onSelectFile = (path: string) => { + setSelectedPath(path) + if (!(path in diffByPath)) + getFileDiff(path, fileByPath.get(path)?.previousFilename) + } + + const onToggleFileConfirm = (path: string, next: boolean) => { + setConfirmed(next ? [...confirmed, path] : confirmed.filter(p => p !== path)) + } + const onToggleDirConfirm = (descendantFiles: string[], next: boolean) => { + setConfirmed( + next + ? [...new Set([...confirmed, ...descendantFiles])] + : confirmed.filter(p => !descendantFiles.includes(p)), + ) + } + + return ( +
    + {/* 左栏:统计条 + 文件树 */} +
    +
    +
    + {`改动 · PR #${issue.pr}`} +
    + {!loading && !filesError && stats.total > 0 && ( +
    + {`共 ${stats.total} 文件 · `} + {`+${stats.additions}`} + {' '} + {`−${stats.deletions}`} + {` · 已查看 ${stats.confirmedCount}/${stats.total}`} +
    + )} +
    +
    + {filesError + ?
    {filesError}
    + : loading + ? 加载中… + : files.length === 0 + ? 该 PR 暂无改动 + : rows.length === 0 + ? 改动均在 tests/ 目录(已忽略) + : ( + setCollapsed((prev) => { + const n = new Set(prev) + n.has(path) ? n.delete(path) : n.add(path) + return n + })} + onSelectFile={onSelectFile} + onToggleFileConfirm={onToggleFileConfirm} + onToggleDirConfirm={onToggleDirConfirm} + /> + )} +
    +
    + + {/* 右栏:选中文件的内联 diff */} +
    + +
    +
    + ) +} diff --git a/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx b/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx new file mode 100644 index 0000000..0c6b075 --- /dev/null +++ b/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx @@ -0,0 +1,138 @@ +/** + * 「改动」面板右栏:把选中单个文件改动前后的原文交给 git-diff-view 内联渲染 + * (主从式,只渲染当前一个文件,故上万行 PR 也不卡)。 + * + * - 左右内容来自 merge_base / head 两版原文;diff 由库在 webview 端算。 + * - 主题跟随 VS Code(body 的 vscode-light/dark class),MutationObserver 监听切换。 + * - 语法高亮用库自带(diffViewHighlight),色彩取库的 light/dark 主题——不会和 VS Code + * 主题 100% 一致,是本方案已知且可接受的小瑕疵。 + */ + +import type { FileDiff } from '../../hooks/usePrFiles' +import { generateDiffFile } from '@git-diff-view/file' +import { DiffModeEnum, DiffView } from '@git-diff-view/react' +import { useEffect, useMemo, useState } from 'react' +import { statusBadge, statusColorVar } from './fileTree' +import '@git-diff-view/react/styles/diff-view.css' + +interface FileDiffPaneProps { + /** 选中文件路径;undefined 表示尚未选中。 */ + path: string | undefined + /** 选中文件状态(A/M/D/R…),仅用于右栏标题徽标。 */ + status: string | undefined + /** 该文件的 diff 内容;undefined 表示请求中(加载态)。 */ + diff: FileDiff | undefined +} + +/** 读当前 VS Code 主题明暗:body 带 vscode-light / vscode-high-contrast-light 即浅色,否则深色。 */ +function readThemeKind(): 'light' | 'dark' { + if (typeof document === 'undefined') + return 'dark' + const cl = document.body.classList + if (cl.contains('vscode-light') || cl.contains('vscode-high-contrast-light')) + return 'light' + return 'dark' +} + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ) +} + +export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) { + const [mode, setMode] = useState(DiffModeEnum.Split) + const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind()) + + // 主题随 VS Code 实时切换。 + useEffect(() => { + const obs = new MutationObserver(() => setTheme(readThemeKind())) + obs.observe(document.body, { attributes: true, attributeFilter: ['class'] }) + return () => obs.disconnect() + }, []) + + // 由两版原文构建并预处理 DiffFile;path/内容/模式/主题任一变就重建。 + // oldContent === newContent(二进制或纯改名)时不出 diff,留空态由下方分支处理。 + const diffFile = useMemo(() => { + if (!path || !diff || diff.error) + return null + if (diff.oldContent === diff.newContent) + return null + const file = generateDiffFile( + path, + diff.oldContent, + path, + diff.newContent, + diff.oldLang ?? '', + diff.newLang ?? '', + ) + file.initTheme(theme) + file.init() + if (mode === DiffModeEnum.Unified) + file.buildUnifiedDiffLines() + else + file.buildSplitDiffLines() + return file + }, [path, diff, mode, theme]) + + if (!path) + return 选择左侧文件查看改动 + + return ( +
    + {/* 右栏标题条:文件路径 + 状态徽标 + Split/Unified 切换 */} +
    + {status && ( + + {statusBadge(status)} + + )} + + {path} + +
    + {([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => ( + + ))} +
    +
    + + {/* 内容区 */} +
    + {diff === undefined + ? 加载中… + : diff.error + ? ( +
    {diff.error}
    + ) + : diffFile === null + ? 无文本差异(可能是二进制文件或纯重命名) + : ( + + )} +
    +
    + ) +} diff --git a/vscode/webview-ui/src/components/prFiles/FileTree.tsx b/vscode/webview-ui/src/components/prFiles/FileTree.tsx new file mode 100644 index 0000000..f59510e --- /dev/null +++ b/vscode/webview-ui/src/components/prFiles/FileTree.tsx @@ -0,0 +1,133 @@ +/** + * 「改动」面板左栏的文件树:目录可折叠、单子目录链合并,文件行点击即选中(驱动右栏 + * diff),行尾「已查看」勾可单独切换;目录勾 = 其下可见文件全已查看,点击级联整目录。 + * + * 纯展示 + 回调:折叠态/选中态/已确认集合都由容器持有,本组件只发语义回调。 + */ + +import type { TreeRow } from './fileTree' +import { Check, ChevronDown, ChevronRight, Folder } from 'lucide-react' +import { useState } from 'react' +import { statusBadge, statusColorVar } from './fileTree' + +interface FileTreeProps { + rows: TreeRow[] + selectedPath: string | undefined + confirmed: ReadonlySet + onToggleCollapse: (path: string) => void + onSelectFile: (path: string) => void + /** 切换单个文件的已查看态,next 为切换后应处于的状态。 */ + onToggleFileConfirm: (path: string, next: boolean) => void + /** 切换整个目录(其下全部可见文件)的已查看态,next 为切换后应处于的状态。 */ + onToggleDirConfirm: (descendantFiles: string[], next: boolean) => void +} + +export function FileTree({ + rows, + selectedPath, + confirmed, + onToggleCollapse, + onSelectFile, + onToggleFileConfirm, + onToggleDirConfirm, +}: FileTreeProps) { + // JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover), + // Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。 + const [hoveredPath, setHoveredPath] = useState(null) + + return ( +
      + {rows.map((row) => { + const indentStyle = { paddingLeft: row.depth * 12 + 8 } + if (row.kind === 'dir') { + // 目录确认 = 其下所有可见文件都已确认(无可见文件则视为未确认)。 + const isConfirmed + = row.descendantFiles.length > 0 + && row.descendantFiles.every(p => confirmed.has(p)) + return ( + // 目录行:点击切换折叠态;左侧箭头随折叠态翻转,path 作为唯一 key。 +
    • setHoveredPath(row.path)} + onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))} + onClick={() => onToggleCollapse(row.path)} + className={`group flex cursor-pointer items-center gap-1.5 py-0.5 pr-2 text-xs text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] ${ + isConfirmed ? 'opacity-60' : '' + }`} + style={indentStyle} + > + {row.collapsed + ? + : } + + {row.name} + +
    • + ) + } + const isConfirmed = confirmed.has(row.path) + const isSelected = selectedPath === row.path + return ( + // 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;title 用完整路径。 +
    • setHoveredPath(row.path)} + onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))} + onClick={() => onSelectFile(row.path)} + title={`${row.status} · ${row.path}`} + className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 ${ + isSelected + ? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]' + : 'hover:bg-[var(--vscode-list-hoverBackground)]' + } ${isConfirmed ? 'opacity-60' : ''}`} + style={indentStyle} + > + + {statusBadge(row.status)} + + {row.name} + +
    • + ) + })} +
    + ) +} diff --git a/vscode/webview-ui/src/components/prFiles/fileTree.ts b/vscode/webview-ui/src/components/prFiles/fileTree.ts new file mode 100644 index 0000000..572456c --- /dev/null +++ b/vscode/webview-ui/src/components/prFiles/fileTree.ts @@ -0,0 +1,144 @@ +/** + * 「改动」文件树的纯函数层:把 `{ path, status }[]` 构建成目录树并拍平成可渲染行, + * 外加状态徽标/颜色/忽略路径等小工具。无 React 依赖,便于单测与复用。 + */ + +/** + * 拍平后的可渲染行: + * - 目录行带层级、(合并单子目录链后的)显示名、完整路径(折叠状态的唯一 key)、 + * 当前是否折叠,以及其子树下所有可见文件的完整路径(用于目录级「已确认」级联); + * - 文件行额外带完整路径与状态。 + */ +export type TreeRow + = | { kind: 'dir', name: string, depth: number, path: string, collapsed: boolean, descendantFiles: string[] } + | { kind: 'file', name: string, depth: number, path: string, status: string } + +interface FileLeaf { name: string, path: string, status: string } + +/** 构树用的中间目录节点:子目录按名索引,文件平铺。 */ +interface DirNode { + dirs: Map + files: FileLeaf[] +} + +function newDirNode(): DirNode { + return { dirs: new Map(), files: [] } +} + +/** 状态首字母徽标(大写),未知状态取首字母兜底。 */ +export function statusBadge(status: string): string { + const s = status.toLowerCase() + if (s.startsWith('add')) + return 'A' + if (s.startsWith('modif')) + return 'M' + if (s.startsWith('delet') || s.startsWith('remov')) + return 'D' + if (s.startsWith('renam')) + return 'R' + if (s.startsWith('cop')) + return 'C' + return (status[0] ?? '?').toUpperCase() +} + +/** 徽标颜色用 VS Code git 装饰主题变量,与编辑器源码管理视图一致。 */ +export function statusColorVar(status: string): string { + const s = status.toLowerCase() + if (s.startsWith('add') || s.startsWith('cop')) + return 'var(--vscode-gitDecoration-addedResourceForeground)' + if (s.startsWith('delet') || s.startsWith('remov')) + return 'var(--vscode-gitDecoration-deletedResourceForeground)' + if (s.startsWith('renam')) + return 'var(--vscode-gitDecoration-renamedResourceForeground, var(--vscode-gitDecoration-modifiedResourceForeground))' + return 'var(--vscode-gitDecoration-modifiedResourceForeground)' +} + +/** PR diff 里跳过测试代码:路径任一段为 `tests` 即视为忽略(匹配 `tests/…` 或 `…/tests/…`)。 */ +export function isIgnoredPath(path: string): boolean { + return path.split('/').some(seg => seg === 'tests') +} + +/** + * 把 `{ path, status }[]` 构建成嵌套目录树(只构树,不拍平、不合并)。 + * + * 路径按 `/` 切分,最后一段是文件、其余是目录层级;折叠的拍平交给 `flattenTree`。 + */ +export function buildDirTree(files: ReadonlyArray<{ path: string, status: string }>): DirNode { + const root = newDirNode() + for (const f of files) { + const segs = f.path.split('/') + const fileName = segs[segs.length - 1] + let node = root + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i] + let child = node.dirs.get(seg) + if (!child) { + child = newDirNode() + node.dirs.set(seg, child) + } + node = child + } + node.files.push({ name: fileName, path: f.path, status: f.status }) + } + return root +} + +/** 收集一个目录子树下所有文件的完整路径(含各级子目录),用于目录级确认级联。 */ +function collectDescendantFiles(node: DirNode): string[] { + const out: string[] = [] + const walk = (n: DirNode) => { + for (const f of n.files) + out.push(f.path) + for (const child of n.dirs.values()) + walk(child) + } + walk(node) + return out +} + +/** + * DFS 拍平目录树成可渲染行列表,并感知折叠状态。 + * + * - 合并单子目录链(VS Code "compact folders" 风格):目录只有 1 个子目录且无文件时, + * 两级目录名用 `/` 连接成一行,递归直到分叉或遇到文件;同时累计该目录行的完整路径 + * (从根拼起、含所有被合并的段),作为折叠状态的唯一 key。 + * - 每层先目录后文件,各自按 `localeCompare` 升序,DFS 顺序输出,`depth` 从 0 起。 + * - 目录被折叠(`collapsed` 含其完整路径)时,只 push 目录行本身,不再递归其子节点。 + */ +export function flattenTree(root: DirNode, collapsed: ReadonlySet): TreeRow[] { + const rows: TreeRow[] = [] + // prefix 是父目录的完整路径(含尾部 `/`),用于拼出当前目录行的折叠 key。 + const emit = (node: DirNode, depth: number, prefix: string) => { + const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b)) + for (const name of dirNames) { + // 合并单子目录链:沿途只要恰好 1 个子目录且本级无文件,就把目录名拼起来; + // path 同步累计每个被合并的段,作为折叠状态的唯一标识。 + let label = name + let path = prefix + name + let cur = node.dirs.get(name)! + while (cur.files.length === 0 && cur.dirs.size === 1) { + const [childName, childNode] = [...cur.dirs.entries()][0] + label += `/${childName}` + path += `/${childName}` + cur = childNode + } + const isCollapsed = collapsed.has(path) + rows.push({ + kind: 'dir', + name: label, + depth, + path, + collapsed: isCollapsed, + descendantFiles: collectDescendantFiles(cur), + }) + // 折叠则隐藏其下所有内容,不再递归。 + if (!isCollapsed) + emit(cur, depth + 1, `${path}/`) + } + const sortedFiles = [...node.files].sort((a, b) => a.name.localeCompare(b.name)) + for (const f of sortedFiles) + rows.push({ kind: 'file', name: f.name, depth, path: f.path, status: f.status }) + } + emit(root, 0, '') + return rows +} diff --git a/vscode/webview-ui/src/hooks/usePrCommits.ts b/vscode/webview-ui/src/hooks/usePrCommits.ts deleted file mode 100644 index 033d901..0000000 --- a/vscode/webview-ui/src/hooks/usePrCommits.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * 「提交」tab 的订阅 hook:按工单号拉取 PR 提交、按需拉提交内文件,并触发原生 - * diff。数据全程实时从扩展侧(Gitea API)取,本 hook 只做缓存与订阅。 - * - * - issueNumber 变化(含首次)时 postMessage `pr-commits/get`,并清空上一工单的 - * 文件缓存与 parentSha 映射,避免串台。 - * - 订阅 `pr-commits/show` / `pr-commit-files/show`,只认 issueNumber 匹配的消息 - * (扩展侧可能并发多工单的回包)。 - * - filesBySha 缓存:同一提交点开第二次不再发请求。 - */ - -import type { PrCommit, PrCommitFile } from '../lib/messages' -import { useCallback, useEffect, useRef, useState } from 'react' -import { onMessage, postMessage } from '../lib/vscode' - -export interface UsePrCommitsResult { - commits: PrCommit[] - commitsError: string | undefined - loadingCommits: boolean - filesBySha: Record - parentShaBySha: Record - filesErrorBySha: Record - confirmedBySha: Record - confirmedCommits: string[] - getFiles: (sha: string) => void - openDiff: (sha: string, parentSha: string | undefined, path: string, status: string) => void - setConfirmed: (sha: string, paths: string[]) => void - markCommitsConfirmed: (shas: string[]) => void -} - -export function usePrCommits(issueNumber: number | undefined): UsePrCommitsResult { - const [commits, setCommits] = useState([]) - const [commitsError, setCommitsError] = useState(undefined) - const [loadingCommits, setLoadingCommits] = useState(false) - const [filesBySha, setFilesBySha] = useState>({}) - const [parentShaBySha, setParentShaBySha] = useState>({}) - const [filesErrorBySha, setFilesErrorBySha] = useState>({}) - const [confirmedBySha, setConfirmedBySha] = useState>({}) - const [confirmedCommits, setConfirmedCommits] = useState([]) - - // issueNumber 装进 ref,让订阅闭包始终读到当前值(订阅只在 mount 挂一次)。 - const issueRef = useRef(issueNumber) - issueRef.current = issueNumber - - // 已发出 files 请求的 sha 集合:防同一提交并发重复请求(缓存到达前的窗口)。 - const requestedRef = useRef>(new Set()) - - useEffect(() => { - const cleanup = onMessage((msg) => { - if (msg.type === 'pr-commits/show') { - if (msg.issueNumber !== issueRef.current) - return - setCommits(msg.commits) - setCommitsError(msg.error) - setConfirmedCommits(msg.confirmedCommits ?? []) - setLoadingCommits(false) - return - } - if (msg.type === 'pr-commit-files/show') { - if (msg.issueNumber !== issueRef.current) - return - setFilesBySha(prev => ({ ...prev, [msg.sha]: msg.files })) - setParentShaBySha(prev => ({ ...prev, [msg.sha]: msg.parentSha })) - setFilesErrorBySha(prev => ({ ...prev, [msg.sha]: msg.error })) - setConfirmedBySha(prev => ({ ...prev, [msg.sha]: msg.confirmed ?? [] })) - } - }) - return cleanup - }, []) - - // 切工单:清空缓存与已请求集合,重新拉提交列表。无工单时只清空。 - useEffect(() => { - setCommits([]) - setCommitsError(undefined) - setFilesBySha({}) - setParentShaBySha({}) - setFilesErrorBySha({}) - setConfirmedBySha({}) - setConfirmedCommits([]) - requestedRef.current = new Set() - if (issueNumber === undefined) { - setLoadingCommits(false) - return - } - setLoadingCommits(true) - postMessage({ type: 'pr-commits/get', issueNumber }) - }, [issueNumber]) - - const getFiles = useCallback((sha: string): void => { - const num = issueRef.current - if (num === undefined) - return - if (requestedRef.current.has(sha)) - return - requestedRef.current.add(sha) - postMessage({ type: 'pr-commit-files/get', issueNumber: num, sha }) - }, []) - - const openDiff = useCallback( - (sha: string, parentSha: string | undefined, path: string, status: string): void => { - const num = issueRef.current - if (num === undefined) - return - postMessage({ type: 'pr-commit-diff/open', issueNumber: num, sha, parentSha, path, status }) - }, - [], - ) - - // 乐观更新本地确认态并落盘到扩展侧;下次拉文件回包会以持久化值校正。 - const setConfirmed = useCallback((sha: string, paths: string[]): void => { - const num = issueRef.current - if (num === undefined) - return - setConfirmedBySha(prev => ({ ...prev, [sha]: paths })) - postMessage({ type: 'pr-review/set', issueNumber: num, sha, confirmed: paths }) - }, []) - - // 乐观更新本地已确认提交集并落盘;下次拉提交列表回包会以持久化值校正。 - const markCommitsConfirmed = useCallback((shas: string[]): void => { - const num = issueRef.current - if (num === undefined) - return - setConfirmedCommits(shas) - postMessage({ type: 'pr-review/set-commits', issueNumber: num, confirmed: shas }) - }, []) - - return { - commits, - commitsError, - loadingCommits, - filesBySha, - parentShaBySha, - filesErrorBySha, - confirmedBySha, - confirmedCommits, - getFiles, - openDiff, - setConfirmed, - markCommitsConfirmed, - } -} diff --git a/vscode/webview-ui/src/hooks/usePrFiles.ts b/vscode/webview-ui/src/hooks/usePrFiles.ts new file mode 100644 index 0000000..762faba --- /dev/null +++ b/vscode/webview-ui/src/hooks/usePrFiles.ts @@ -0,0 +1,130 @@ +/** + * 「改动」tab 的订阅 hook:按工单号拉取整个 PR 的改动文件清单,并按需取单个文件 + * 改动前后的原文(供 git-diff-view 内联渲染)。数据全程实时从扩展侧(Gitea)取, + * 本 hook 只做缓存与订阅。 + * + * - issueNumber 变化(含首次)时 postMessage `pr-files/get`,并清空上一工单缓存。 + * - 订阅 `pr-files/show` / `pr-file-diff/show`,只认 issueNumber 匹配的消息 + * (扩展侧可能并发多工单的回包)。 + * - diffByPath 缓存:同一文件点开第二次不再发请求。 + * - 已确认(已查看)按 head sha 落盘,故 setConfirmed 要带当前 headSha。 + */ + +import type { PrFile } from '../lib/messages' +import { useCallback, useEffect, useRef, useState } from 'react' +import { onMessage, postMessage } from '../lib/vscode' + +/** 单个文件改动前后的原文 + 语言推断;error 表示该文件取内容失败。 */ +export interface FileDiff { + oldContent: string + newContent: string + oldLang?: string + newLang?: string + error?: string +} + +export interface UsePrFilesResult { + files: PrFile[] + headSha: string + filesError: string | undefined + loading: boolean + confirmed: string[] + /** key ∈ diffByPath 即「已请求/已到」;值为内容。仅 key 在但值 undefined 不会出现。 */ + diffByPath: Record + getFileDiff: (path: string, previousPath?: string) => void + setConfirmed: (paths: string[]) => void +} + +export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { + const [files, setFiles] = useState([]) + const [headSha, setHeadSha] = useState('') + const [filesError, setFilesError] = useState(undefined) + const [loading, setLoading] = useState(false) + const [confirmed, setConfirmedState] = useState([]) + const [diffByPath, setDiffByPath] = useState>({}) + + // issueNumber / headSha 装进 ref,让订阅闭包与回调始终读到当前值。 + const issueRef = useRef(issueNumber) + issueRef.current = issueNumber + const headShaRef = useRef('') + headShaRef.current = headSha + + // 已发出 diff 请求的 path 集合:防同一文件并发重复请求(缓存到达前的窗口)。 + const requestedRef = useRef>(new Set()) + + useEffect(() => { + const cleanup = onMessage((msg) => { + if (msg.type === 'pr-files/show') { + if (msg.issueNumber !== issueRef.current) + return + setFiles(msg.files) + setHeadSha(msg.headSha) + setConfirmedState(msg.confirmed ?? []) + setFilesError(msg.error) + setLoading(false) + return + } + if (msg.type === 'pr-file-diff/show') { + if (msg.issueNumber !== issueRef.current) + return + setDiffByPath(prev => ({ + ...prev, + [msg.path]: { + oldContent: msg.oldContent, + newContent: msg.newContent, + oldLang: msg.oldLang, + newLang: msg.newLang, + error: msg.error, + }, + })) + } + }) + return cleanup + }, []) + + // 切工单:清空缓存与已请求集合,重新拉文件清单。无工单时只清空。 + useEffect(() => { + setFiles([]) + setHeadSha('') + setFilesError(undefined) + setConfirmedState([]) + setDiffByPath({}) + requestedRef.current = new Set() + if (issueNumber === undefined) { + setLoading(false) + return + } + setLoading(true) + postMessage({ type: 'pr-files/get', issueNumber }) + }, [issueNumber]) + + const getFileDiff = useCallback((path: string, previousPath?: string): void => { + const num = issueRef.current + if (num === undefined) + return + if (requestedRef.current.has(path)) + return + requestedRef.current.add(path) + postMessage({ type: 'pr-file-diff/get', issueNumber: num, path, previousPath }) + }, []) + + // 乐观更新本地确认态并落盘(键含当前 headSha);下次拉文件回包会以持久化值校正。 + const setConfirmed = useCallback((paths: string[]): void => { + const num = issueRef.current + if (num === undefined) + return + setConfirmedState(paths) + postMessage({ type: 'pr-review/set', issueNumber: num, headSha: headShaRef.current, confirmed: paths }) + }, []) + + return { + files, + headSha, + filesError, + loading, + confirmed, + diffByPath, + getFileDiff, + setConfirmed, + } +} diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index da78b6c..25e7f6b 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -11,18 +11,16 @@ export interface ToastLink { url: string } -/** PR 提交的精简视图(提交列表渲染用)。 */ -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 type ToastLevel = 'info' | 'success' | 'error' @@ -117,8 +115,8 @@ export type ExtensionToWebview | { type: 'issue/remove', issueNumber: number } | { type: 'profiles/show', data: ProfilesData } | { type: 'managed-sessions/show', data: ManagedSessionsData } - | { 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' } @@ -189,8 +187,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[] } diff --git a/vscode/webview-ui/src/shims.d.ts b/vscode/webview-ui/src/shims.d.ts new file mode 100644 index 0000000..db5c6e3 --- /dev/null +++ b/vscode/webview-ui/src/shims.d.ts @@ -0,0 +1,2 @@ +// 让 tsc 认识副作用式 CSS 导入(如 git-diff-view 的样式表);Vite 负责真正打包。 +declare module '*.css'