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

- 左树右 diff:左栏全 PR 文件树 + 统计条(共改 N · +X−Y · 已查看 M/N),右栏选中单文件内联 diff
- 用 @git-diff-view/react 只渲染当前选中文件,绕开上万行全量渲染的性能坑
- gitea 新增 listPullRequestFiles、getPullRequest 取 merge_base/head,按需回两版原文
- diff 走 merge_base→head 三点口径(与 Gitea 网页一致),getRawFile 404 兜底成全增/全删/改名
- 已查看态按 head sha 落盘、新 push 自然重置;去掉提交列表、原生 diff 与 spx-gitea 虚拟文档 provider
This commit is contained in:
2026-06-29 10:52:36 +08:00
parent ceea62ed16
commit fb505d59c5
18 changed files with 1149 additions and 1127 deletions
+2
View File
@@ -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",
@@ -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="会话"
/>
<TabButton
active={tab === 'commits'}
onClick={() => setTab('commits')}
icon={<GitCommitHorizontal className="size-4" />}
title="提交"
active={tab === 'changes'}
onClick={() => setTab('changes')}
icon={<FileDiff className="size-4" />}
title="改动"
/>
</div>
@@ -143,8 +143,8 @@ export function BottomTabs(props: BottomTabsProps) {
onCloseTab={props.onManagedSessionCloseTab}
/>
)}
{tab === 'commits' && (
<PrCommitsPanel issue={props.issue} />
{tab === 'changes' && (
<PrFilesPanel issue={props.issue} />
)}
</div>
</div>
@@ -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<string, DirNode>
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<string>): 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<string | undefined>(undefined)
// 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
// JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover)
// Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。
const [hoveredPath, setHoveredPath] = useState<string | null>(null)
// 提交行的悬停态(与文件行的 hoveredPath 分开),同样 JS 驱动确认按钮显隐。
const [hoveredCommit, setHoveredCommit] = useState<string | null>(null)
// 提交列表 <ul>,用于方向键切换后把选中项滚进可视区。
const commitListRef = useRef<HTMLUListElement | null>(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 (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
</div>
)
}
if (!issue.pr) {
return (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
PR
</div>
)
}
// 提交列表方向键切换:未选中时 ArrowDown 选首、ArrowUp 选末;已选中则按下标夹取边界移动。
const onCommitListKeyDown = (e: ReactKeyboardEvent<HTMLUListElement>) => {
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 (
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
{/* 上半区:提交列表 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-b border-[var(--vscode-panel-border)]">
<div className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
{`提交 #${issue.pr}`}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{commitsError
? (
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">
{commitsError}
</div>
)
: loadingCommits
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
</div>
)
: commits.length === 0
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
PR
</div>
)
: (
<ul
ref={commitListRef}
tabIndex={0}
role="listbox"
aria-label="PR 提交列表"
onKeyDown={onCommitListKeyDown}
className="flex flex-col outline-none"
>
{commits.map((c) => {
const isConfirmed = confirmedCommitSet.has(c.sha)
return (
<li
key={c.sha}
data-sha={c.sha}
role="option"
aria-selected={selectedSha === c.sha}
onMouseEnter={() => 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' : ''}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[10px] text-[var(--vscode-descriptionForeground)]">
{c.sha.slice(0, 7)}
</span>
<span className="min-w-0 truncate text-xs" title={c.message}>
{c.message}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
const next = isConfirmed
? confirmedCommits.filter(s => s !== c.sha)
: [...confirmedCommits, c.sha]
markCommitsConfirmed(next)
}}
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredCommit === c.sha ? 1 : 0,
color: isConfirmed
? 'var(--vscode-gitDecoration-addedResourceForeground)'
: 'var(--vscode-descriptionForeground)',
}}
>
<Check className="size-3.5" />
</button>
</div>
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
{[c.authorName, formatDate(c.date)].filter(Boolean).join(' · ')}
</div>
</li>
)
})}
</ul>
)}
</div>
</div>
{/* 下半区:选中提交的文件清单 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
</div>
<div className="min-h-0 flex-1 overflow-auto">
{!selectedSha
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
</div>
)
: selectedFilesError
? (
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">
{selectedFilesError}
</div>
)
: selectedFiles === undefined
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
</div>
)
: selectedFiles.length === 0
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
</div>
)
// selectedFiles 非空但全被 tests/ 过滤掉,行列表才会为空。
: fileTreeRows.length === 0
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
tests/
</div>
)
: (
<ul className="flex flex-col py-0.5">
{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。
<li
key={row.path}
onMouseEnter={() => 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
? <ChevronRight className="size-3.5 shrink-0" />
: <ChevronDown className="size-3.5 shrink-0" />}
<Folder
className="size-3.5 shrink-0"
style={{ color: 'var(--vscode-descriptionForeground)' }}
/>
<span className="min-w-0 truncate">{row.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
const next = isConfirmed
? [...confirmed].filter(p => !row.descendantFiles.includes(p))
: [...new Set([...confirmed, ...row.descendantFiles])]
setConfirmed(selectedSha, next)
}}
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
color: isConfirmed
? 'var(--vscode-gitDecoration-addedResourceForeground)'
: 'var(--vscode-descriptionForeground)',
}}
>
<Check className="size-3.5" />
</button>
</li>
)
}
const isConfirmed = confirmed.has(row.path)
return (
// 文件行:点击触发 diff,title 用完整路径。
<li
key={row.path}
onMouseEnter={() => 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}
>
<span
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
style={{ color: statusColorVar(row.status) }}
>
{statusBadge(row.status)}
</span>
<span className="min-w-0 truncate text-xs">{row.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
const next = isConfirmed
? [...confirmed].filter(p => p !== row.path)
: [...confirmed, row.path]
setConfirmed(selectedSha, next)
}}
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
color: isConfirmed
? 'var(--vscode-gitDecoration-addedResourceForeground)'
: 'var(--vscode-descriptionForeground)',
}}
>
<Check className="size-3.5" />
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)
}
@@ -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 (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
{children}
</div>
)
}
export function PrFilesPanel({ issue }: PrFilesPanelProps) {
const issueNumber = issue?.number
const {
files,
filesError,
loading,
confirmed,
diffByPath,
getFileDiff,
setConfirmed,
} = usePrFiles(issueNumber)
const [selectedPath, setSelectedPath] = useState<string | undefined>(undefined)
// 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。
const [collapsed, setCollapsed] = useState<Set<string>>(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 <Centered></Centered>
if (!issue.pr)
return <Centered> PR</Centered>
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 (
<div className="flex h-full w-full overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
{/* 左栏:统计条 + 文件树 */}
<div className="flex w-72 shrink-0 flex-col overflow-hidden border-r border-[var(--vscode-panel-border)]">
<div className="shrink-0 border-b border-[var(--vscode-panel-border)] px-2 py-1">
<div className="text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
{`改动 · PR #${issue.pr}`}
</div>
{!loading && !filesError && stats.total > 0 && (
<div className="mt-0.5 truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
{`${stats.total} 文件 · `}
<span style={{ color: 'var(--vscode-gitDecoration-addedResourceForeground)' }}>{`+${stats.additions}`}</span>
{' '}
<span style={{ color: 'var(--vscode-gitDecoration-deletedResourceForeground)' }}>{`${stats.deletions}`}</span>
{` · 已查看 ${stats.confirmedCount}/${stats.total}`}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{filesError
? <div className="p-3 text-xs text-[var(--vscode-errorForeground)]">{filesError}</div>
: loading
? <Centered></Centered>
: files.length === 0
? <Centered> PR </Centered>
: rows.length === 0
? <Centered> tests/ </Centered>
: (
<FileTree
rows={rows}
selectedPath={selectedPath}
confirmed={confirmedSet}
onToggleCollapse={path => setCollapsed((prev) => {
const n = new Set(prev)
n.has(path) ? n.delete(path) : n.add(path)
return n
})}
onSelectFile={onSelectFile}
onToggleFileConfirm={onToggleFileConfirm}
onToggleDirConfirm={onToggleDirConfirm}
/>
)}
</div>
</div>
{/* 右栏:选中文件的内联 diff */}
<div className="min-w-0 flex-1 overflow-hidden">
<FileDiffPane
path={selectedPath}
status={selectedFile?.status}
diff={selectedPath ? diffByPath[selectedPath] : undefined}
/>
</div>
</div>
)
}
@@ -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 (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
{children}
</div>
)
}
export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) {
const [mode, setMode] = useState<DiffModeEnum>(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 <Centered></Centered>
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 右栏标题条:文件路径 + 状态徽标 + Split/Unified 切换 */}
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--vscode-panel-border)] px-2 py-1">
{status && (
<span
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
style={{ color: statusColorVar(status) }}
>
{statusBadge(status)}
</span>
)}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-[var(--vscode-foreground)]" title={path}>
{path}
</span>
<div className="flex shrink-0 overflow-hidden rounded border border-[var(--vscode-panel-border)] text-[10px]">
{([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => (
<button
key={label}
type="button"
onClick={() => setMode(m)}
className={`px-1.5 py-0.5 transition-colors ${
mode === m
? 'bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)]'
: 'text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* 内容区 */}
<div className="min-h-0 flex-1 overflow-auto bg-[var(--vscode-editor-background)] text-xs">
{diff === undefined
? <Centered></Centered>
: diff.error
? (
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">{diff.error}</div>
)
: diffFile === null
? <Centered></Centered>
: (
<DiffView
diffFile={diffFile}
diffViewMode={mode}
diffViewTheme={theme}
diffViewHighlight
diffViewFontSize={12}
/>
)}
</div>
</div>
)
}
@@ -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<string>
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<string | null>(null)
return (
<ul className="flex flex-col py-0.5">
{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。
<li
key={row.path}
onMouseEnter={() => 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
? <ChevronRight className="size-3.5 shrink-0" />
: <ChevronDown className="size-3.5 shrink-0" />}
<Folder
className="size-3.5 shrink-0"
style={{ color: 'var(--vscode-descriptionForeground)' }}
/>
<span className="min-w-0 truncate">{row.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleDirConfirm(row.descendantFiles, !isConfirmed)
}}
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
color: isConfirmed
? 'var(--vscode-gitDecoration-addedResourceForeground)'
: 'var(--vscode-descriptionForeground)',
}}
>
<Check className="size-3.5" />
</button>
</li>
)
}
const isConfirmed = confirmed.has(row.path)
const isSelected = selectedPath === row.path
return (
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;title 用完整路径。
<li
key={row.path}
onMouseEnter={() => 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}
>
<span
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
style={{ color: statusColorVar(row.status) }}
>
{statusBadge(row.status)}
</span>
<span className="min-w-0 truncate text-xs">{row.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleFileConfirm(row.path, !isConfirmed)
}}
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
color: isConfirmed
? 'var(--vscode-gitDecoration-addedResourceForeground)'
: 'var(--vscode-descriptionForeground)',
}}
>
<Check className="size-3.5" />
</button>
</li>
)
})}
</ul>
)
}
@@ -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<string, DirNode>
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<string>): 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
}
-141
View File
@@ -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<string, PrCommitFile[]>
parentShaBySha: Record<string, string | undefined>
filesErrorBySha: Record<string, string | undefined>
confirmedBySha: Record<string, string[]>
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<PrCommit[]>([])
const [commitsError, setCommitsError] = useState<string | undefined>(undefined)
const [loadingCommits, setLoadingCommits] = useState(false)
const [filesBySha, setFilesBySha] = useState<Record<string, PrCommitFile[]>>({})
const [parentShaBySha, setParentShaBySha] = useState<Record<string, string | undefined>>({})
const [filesErrorBySha, setFilesErrorBySha] = useState<Record<string, string | undefined>>({})
const [confirmedBySha, setConfirmedBySha] = useState<Record<string, string[]>>({})
const [confirmedCommits, setConfirmedCommits] = useState<string[]>([])
// issueNumber 装进 ref,让订阅闭包始终读到当前值(订阅只在 mount 挂一次)。
const issueRef = useRef<number | undefined>(issueNumber)
issueRef.current = issueNumber
// 已发出 files 请求的 sha 集合:防同一提交并发重复请求(缓存到达前的窗口)。
const requestedRef = useRef<Set<string>>(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,
}
}
+130
View File
@@ -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<string, FileDiff>
getFileDiff: (path: string, previousPath?: string) => void
setConfirmed: (paths: string[]) => void
}
export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
const [files, setFiles] = useState<PrFile[]>([])
const [headSha, setHeadSha] = useState<string>('')
const [filesError, setFilesError] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [confirmed, setConfirmedState] = useState<string[]>([])
const [diffByPath, setDiffByPath] = useState<Record<string, FileDiff>>({})
// issueNumber / headSha 装进 ref,让订阅闭包与回调始终读到当前值。
const issueRef = useRef<number | undefined>(issueNumber)
issueRef.current = issueNumber
const headShaRef = useRef<string>('')
headShaRef.current = headSha
// 已发出 diff 请求的 path 集合:防同一文件并发重复请求(缓存到达前的窗口)。
const requestedRef = useRef<Set<string>>(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,
}
}
+13 -17
View File
@@ -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[] }
+2
View File
@@ -0,0 +1,2 @@
// 让 tsc 认识副作用式 CSS 导入(如 git-diff-view 的样式表);Vite 负责真正打包。
declare module '*.css'