Files
superwork/vscode/webview-ui/src/components/PrCommitsPanel.tsx
T
2026-06-23 05:02:15 +08:00

534 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「提交」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>
)
}